Compare commits

...

43 Commits

Author SHA1 Message Date
3e09c934d4 fix : list pengaduan error 2025-11-17 17:25:14 +08:00
282b9678b3 upd: dashboard admin
Deskripsi:
- detail pengaduan
- menerima pengaduan
- mengerjakan pengaduan/
- menyelesaikan pengaduan
- menolak pengaduan

No Issues
2025-11-17 17:13:11 +08:00
ceed3e67c7 Merge pull request 'upd : dahboard admin' (#28) from amalia/17-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/28
2025-11-17 13:42:32 +08:00
04b5d26507 upd : dahboard admin
Deskripsi:
- detail pengaduan

No Issues
2025-11-17 13:41:34 +08:00
327434b42e Merge pull request 'upd: warga route' (#27) from amalia/14-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/27
2025-11-14 17:27:15 +08:00
c4e4aaffe7 upd: warga route 2025-11-14 17:26:42 +08:00
51e323c264 Merge pull request 'amalia/14-nov-25' (#26) from amalia/14-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/26
2025-11-14 17:25:42 +08:00
b5d3b2bd08 upd: conflict 2025-11-14 17:24:51 +08:00
67c066990e update dashboard admin
Deskripsi:
- list warga
- detail warga

No Issues
2025-11-14 17:19:50 +08:00
6c6ee02cf0 upd: dahsboar admin
Deskripsi:
- list kategori pelayanan surat
- edit kategori pelayanan surat
- tambah kategori pelayanan surat
- hapush kategori pelayanan surat

No Issues
2025-11-14 14:32:32 +08:00
bipproduction
a3c07ca255 tambahannnya 2025-11-14 11:42:03 +08:00
bipproduction
76c425700a tambahannnya 2025-11-14 11:05:08 +08:00
bipproduction
467dbfa296 tambahannnya 2025-11-14 11:02:12 +08:00
bipproduction
719f92cbe8 tambahannnya 2025-11-14 11:00:48 +08:00
bipproduction
2075d0fba1 tambahannnya 2025-11-14 10:56:08 +08:00
bipproduction
7d98f9f61c tambahannnya 2025-11-14 10:53:20 +08:00
bipproduction
1a5bd72237 tambahannnya 2025-11-14 10:51:51 +08:00
bipproduction
85cb36289c tambahannnya 2025-11-14 10:46:23 +08:00
8d535793b1 upd: dashboard admin kategori pengaduan
Deskripsi:
- hapus kategori pengaduan

No Issues
2025-11-14 10:42:24 +08:00
bipproduction
77cbb6062b tambahannnya 2025-11-14 10:24:49 +08:00
bipproduction
e0bef23eab tambahannnya 2025-11-14 10:19:44 +08:00
bipproduction
4ea72fb846 fix mcp generator 2025-11-13 21:58:55 +08:00
bipproduction
d380c859a4 fix mcp generator 2025-11-13 20:36:26 +08:00
bipproduction
78f2263c86 fix mcp generator 2025-11-13 19:37:09 +08:00
254d05f5ea Merge pull request 'amalia/13-nov-25' (#24) from amalia/13-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/24
2025-11-13 17:44:13 +08:00
039524d092 upd: dashboard admin
Deskripsi:
- list user
- tambah usr
- delete user

- beserta api

NO Issues
2025-11-13 17:43:19 +08:00
cc293d3bad upd: profile
Deskripsi
- tampilan edit profile
- integrasi api edit profile
- tampilan edit password
- integrasi api update password

No Issues
2025-11-13 16:34:01 +08:00
6e1d3ecb56 Merge pull request 'amalia/12-nov-25' (#23) from amalia/12-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/23
2025-11-12 17:41:45 +08:00
5101a21f54 upd: api mcp pengaduan
Deskripsi:
- bahasa nya lebih bisa di mengerti ai bukan bahasa developer

No Issues
2025-11-12 17:41:00 +08:00
503c3e330d upd: dashboard admin
Deskripsi:
- tampilan list kategori pengaduan
- tambah kategori pengaduan
- edit kategori pengaduan

No Issues
2025-11-12 17:20:14 +08:00
001c3df47d Merge pull request 'upd: coba api create pengaduan dengan gambar' (#22) from amalia/12-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/22
2025-11-12 15:36:33 +08:00
a4167cfc8b upd: coba api create pengaduan dengan gambar 2025-11-12 15:35:41 +08:00
5b240b782a Merge pull request 'upd: api create pengaduan dg image' (#21) from amalia/12-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/21
2025-11-12 15:19:49 +08:00
14e2d711b3 upd: api create pengaduan dg image 2025-11-12 15:19:19 +08:00
dc3ae99c05 Merge pull request 'amalia/12-nov-25' (#20) from amalia/12-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/20
2025-11-12 14:58:14 +08:00
63c88161d3 upd: coba api pengaduan dengan upload gambar 2025-11-12 14:57:22 +08:00
eacc8fc220 update: dashboard admin
Deskripsi:
- list data konfigurasi api
- edit data konfigurasi api
- integrasi api

No Issues
2025-11-12 14:40:13 +08:00
422ca5a2cc Merge pull request 'amalia/11-nov-25' (#19) from amalia/11-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/19
2025-11-11 17:49:00 +08:00
adae0d3db1 upd: tampil gambar
Deskripsi:
- masih blm bisa

No Issues
2025-11-11 16:52:52 +08:00
715a929e13 upd: dashboard admin
Deskripsi:
- tampilan profile;2A
- tampilan list category pengaduan
- tampilan list category pelayanan surat
- tampilan list configurasi desa

No Issues
2025-11-11 15:31:00 +08:00
5b0f9b06d8 update: dashboard
Deskripsi:
- tampilan list warga
- tampilan detail warga

No Issues
2025-11-11 12:01:54 +08:00
663e36bc4b update: dashboard admin
Deskripsi:
- list pelayanan surat
- detail pelayanan surat

No Issues
2025-11-11 11:11:21 +08:00
ddefbbbbff update: dashboard admin
Deskripsi:
- tampilan detail pengaduan

No Issues
2025-11-11 11:00:54 +08:00
30 changed files with 4398 additions and 593 deletions

View File

@@ -1,7 +1,7 @@
import "@mantine/core/styles.css";
import "@mantine/notifications/styles.css";
import "@mantine/dates/styles.css";
import { Notifications } from "@mantine/notifications";
import "@mantine/notifications/styles.css";
import { MantineProvider } from "@mantine/core";
import AppRoutes from "./AppRoutes";

View File

@@ -18,10 +18,14 @@ import Home from "./pages/Home";
import CredentialPage from "./pages/scr/dashboard/credential/credential_page";
import DashboardHome from "./pages/scr/dashboard/dashboard_home";
import ListPelayananPage from "./pages/scr/dashboard/pelayanan-surat/list_pelayanan_page";
import DetailPelayananPage from "./pages/scr/dashboard/pelayanan-surat/detail_pelayanan_page";
import DetailWargaPage from "./pages/scr/dashboard/warga/detail_warga_page";
import ListWargaPage from "./pages/scr/dashboard/warga/list_warga_page";
import ListPage from "./pages/scr/dashboard/pengaduan/list_page";
import DetailPage from "./pages/scr/dashboard/pengaduan/detail_page";
import ApikeyPage from "./pages/scr/dashboard/apikey/apikey_page";
import DashboardLayout from "./pages/scr/dashboard/dashboard_layout";
import DetailSettingPage from "./pages/scr/dashboard/setting/detail_setting_page";
import ScrLayout from "./pages/scr/scr_layout";
import DirPage from "./pages/dir/dir_page";
import NotFound from "./pages/NotFound";
@@ -99,6 +103,18 @@ export default function AppRoutes() {
path="/scr/dashboard/pelayanan-surat/list-pelayanan"
element={<ListPelayananPage />}
/>
<Route
path="/scr/dashboard/pelayanan-surat/detail-pelayanan"
element={<DetailPelayananPage />}
/>
<Route
path="/scr/dashboard/warga/detail-warga"
element={<DetailWargaPage />}
/>
<Route
path="/scr/dashboard/warga/list-warga"
element={<ListWargaPage />}
/>
<Route
path="/scr/dashboard/pengaduan/list"
element={<ListPage />}
@@ -111,6 +127,10 @@ export default function AppRoutes() {
path="/scr/dashboard/apikey/apikey"
element={<ApikeyPage />}
/>
<Route
path="/scr/dashboard/setting/detail-setting"
element={<DetailSettingPage />}
/>
</Route>
</Route>
<Route path="/dir/dir" element={<DirPage />} />

View File

@@ -20,9 +20,13 @@ const clientRoutes = {
"/scr/dashboard/credential/credential": "/scr/dashboard/credential/credential",
"/scr/dashboard/dashboard-home": "/scr/dashboard/dashboard-home",
"/scr/dashboard/pelayanan-surat/list-pelayanan": "/scr/dashboard/pelayanan-surat/list-pelayanan",
"/scr/dashboard/pelayanan-surat/detail-pelayanan": "/scr/dashboard/pelayanan-surat/detail-pelayanan",
"/scr/dashboard/warga/detail-warga": "/scr/dashboard/warga/detail-warga",
"/scr/dashboard/warga/list-warga": "/scr/dashboard/warga/list-warga",
"/scr/dashboard/pengaduan/list": "/scr/dashboard/pengaduan/list",
"/scr/dashboard/pengaduan/detail": "/scr/dashboard/pengaduan/detail",
"/scr/dashboard/apikey/apikey": "/scr/dashboard/apikey/apikey",
"/scr/dashboard/setting/detail-setting": "/scr/dashboard/setting/detail-setting",
"/dir/dir": "/dir/dir",
"/*": "/*"
} as const;

View File

@@ -0,0 +1,171 @@
import apiFetch from "@/lib/apiFetch";
import {
ActionIcon,
Button,
Divider,
Flex,
Group,
Input,
Modal,
Stack,
Table,
Title,
Tooltip,
} from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import { IconEdit } from "@tabler/icons-react";
import { useState } from "react";
import useSWR from "swr";
import notification from "./notificationGlobal";
export default function DesaSetting() {
const [btnDisable, setBtnDisable] = useState(false);
const [btnLoading, setBtnLoading] = useState(false);
const [opened, { open, close }] = useDisclosure(false);
const { data, mutate, isLoading } = useSWR("/", () =>
apiFetch.api["configuration-desa"].list.get(),
);
const list = data?.data || [];
const [dataEdit, setDataEdit] = useState({
id: "",
value: "",
name: "",
});
useShallowEffect(() => {
mutate();
}, []);
async function handleEdit() {
try {
setBtnLoading(true);
const res = await apiFetch.api["configuration-desa"].edit.post(dataEdit);
if (res.status === 200) {
mutate();
close();
notification({
title: "Success",
message: "Your settings have been saved",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to edit configuration",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to edit configuration",
type: "error",
});
} finally {
setBtnLoading(false);
}
}
function chooseEdit({
data,
}: {
data: { id: string; value: string; name: string };
}) {
setDataEdit(data);
open();
}
function onValidation({ kat, value }: { kat: "value"; value: string }) {
if (value.length < 1) {
setBtnDisable(true);
} else {
setBtnDisable(false);
}
if (kat === "value") {
setDataEdit({ ...dataEdit, value: value });
}
}
useShallowEffect(() => {
if (dataEdit.value.length > 0) {
setBtnDisable(false);
}
}, [dataEdit.id]);
return (
<>
<Modal
opened={opened}
onClose={close}
title={"Edit"}
centered
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="ld">
<Input.Wrapper label={dataEdit.name}>
<Input
value={dataEdit.value}
onChange={(e) =>
onValidation({ kat: "value", value: e.target.value })
}
/>
</Input.Wrapper>
<Group justify="center" grow>
<Button variant="light" onClick={close}>
Batal
</Button>
<Button
variant="filled"
onClick={handleEdit}
disabled={btnDisable}
loading={btnLoading}
>
Simpan
</Button>
</Group>
</Stack>
</Modal>
<Stack gap={"md"}>
<Flex align="center" justify="space-between">
<Title order={4} c="gray.2">
Pengaturan Desa
</Title>
</Flex>
<Divider my={0} />
<Stack gap={"md"}>
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Nama</Table.Th>
<Table.Th>Value</Table.Th>
<Table.Th>Aksi</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{list?.map((v: any) => (
<Table.Tr key={v.id}>
<Table.Td>{v.name}</Table.Td>
<Table.Td>{v.value}</Table.Td>
<Table.Td>
<Tooltip label="Edit Setting">
<ActionIcon
variant="light"
size="sm"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => chooseEdit({ data: v })}
>
<IconEdit size={20} />
</ActionIcon>
</Tooltip>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
</Stack>
</>
);
}

View File

@@ -0,0 +1,612 @@
import apiFetch from "@/lib/apiFetch";
import {
ActionIcon,
Button,
Divider,
Flex,
Grid,
Group,
Input,
List,
Modal,
Stack,
Table,
TagsInput,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import { IconEdit, IconEye, IconPlus, IconTrash } from "@tabler/icons-react";
import { useState } from "react";
import useSWR from "swr";
import notification from "./notificationGlobal";
export default function KategoriPelayananSurat() {
const [openedDelete, { open: openDelete, close: closeDelete }] =
useDisclosure(false);
const [openedDetail, { open: openDetail, close: closeDetail }] =
useDisclosure(false);
const [btnLoading, setBtnLoading] = useState(false);
const [opened, { open, close }] = useDisclosure(false);
const [openedTambah, { open: openTambah, close: closeTambah }] =
useDisclosure(false);
const [dataDelete, setDataDelete] = useState("");
const { data, mutate, isLoading } = useSWR("/", () =>
apiFetch.api.pelayanan.category.get(),
);
const list = data?.data || [];
const [dataChoose, setDataChoose] = useState({
id: "",
name: "",
syaratDokumen: [{ name: "", desc: "" }],
dataText: [""],
});
const [dataTambah, setDataTambah] = useState({
name: "",
syaratDokumen: [{ name: "", desc: "" }],
dataText: [""],
});
useShallowEffect(() => {
mutate();
}, []);
async function handleCreate() {
try {
setBtnLoading(true);
const cleanedDataText = dataTambah.dataText
.map((v) => v.trim())
.filter((v) => v !== "");
const cleanedSyarat = dataTambah.syaratDokumen
.map((item) => ({
name: item.name.trim(),
desc: item.desc.trim(),
}))
.filter((item) => item.name !== "" && item.desc !== "");
const cleanedTambah = {
name: dataTambah.name.trim(),
syaratDokumen: cleanedSyarat,
dataText: cleanedDataText,
};
const res =
await apiFetch.api.pelayanan.category.create.post(cleanedTambah);
if (res.status === 200) {
mutate();
closeTambah();
setDataTambah({
name: "",
syaratDokumen: [{ name: "", desc: "" }],
dataText: [""],
});
notification({
title: "Success",
message: "Your category have been saved",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to create category",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to create category",
type: "error",
});
} finally {
setBtnLoading(false);
}
}
async function handleEdit() {
try {
setBtnLoading(true);
const cleanedDataText = dataChoose.dataText
.map((v) => v.trim())
.filter((v) => v !== "");
const cleanedSyarat = dataChoose.syaratDokumen
.map((item) => ({
name: item.name.trim(),
desc: item.desc.trim(),
}))
.filter((item) => item.name !== "" && item.desc !== "");
const res = await apiFetch.api.pelayanan.category.update.post({
id: dataChoose.id,
name: dataChoose.name,
syaratDokumen: cleanedSyarat,
dataText: cleanedDataText,
});
if (res.status === 200) {
mutate();
close();
notification({
title: "Success",
message: "Your category have been saved",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to edit category",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to edit category",
type: "error",
});
} finally {
setBtnLoading(false);
}
}
async function handleDelete() {
try {
setBtnLoading(true);
const res = await apiFetch.api.pelayanan.category.delete.post({
id: dataDelete,
});
if (res.status === 200) {
mutate();
closeDelete();
notification({
title: "Success",
message: "Your category have been deleted",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to delete category",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to delete category",
type: "error",
});
} finally {
setBtnLoading(false);
}
}
function handleAddSyarat() {
setDataChoose({
...dataChoose,
syaratDokumen: [...dataChoose.syaratDokumen, { name: "", desc: "" }],
});
}
function handleDeleteSyarat(index: number) {
setDataChoose({
...dataChoose,
syaratDokumen: dataChoose.syaratDokumen.filter((_, i) => i !== index),
});
}
function handleEditSyarat(
index: number,
data: { name: string; desc: string },
) {
setDataChoose({
...dataChoose,
syaratDokumen: dataChoose.syaratDokumen.map((v, i) =>
i === index ? data : v,
),
});
}
return (
<>
{/* Modal Edit */}
<Modal
opened={opened}
onClose={close}
title={"Edit"}
size="xl"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="lg">
<Input.Wrapper label="Kategori">
<Input
value={dataChoose.name}
onChange={(e) =>
setDataChoose({ ...dataChoose, name: e.target.value })
}
/>
</Input.Wrapper>
<TagsInput
label="Data Pelengkap"
placeholder="Tambah data pelengkap"
splitChars={[","]}
value={dataChoose.dataText}
onChange={(value) =>
setDataChoose({ ...dataChoose, dataText: value })
}
/>
<Flex direction={"column"} gap={"md"}>
<Group>
<Text size="sm" c={"white"}>
Syarat dokumen
</Text>
<Tooltip label="Tambah Syarat Dokumen">
<ActionIcon
variant="light"
size="sm"
color="blue"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={handleAddSyarat}
>
<IconPlus size={20} />
</ActionIcon>
</Tooltip>
</Group>
{dataChoose?.syaratDokumen?.map((v: any, i: number) => (
<Grid
key={i}
style={{
borderBottom: "1px solid gray",
paddingBottom: "10px",
}}
>
<Grid.Col
span={1}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Tooltip label="Delete Syarat Dokumen">
<ActionIcon
variant="light"
size="sm"
color="red"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => {
handleDeleteSyarat(i);
}}
>
<IconTrash size={20} />
</ActionIcon>
</Tooltip>
</Grid.Col>
<Grid.Col span={5}>
<Input.Wrapper label="Nama">
<Input
value={v.name}
onChange={(e) =>
handleEditSyarat(i, {
name: e.target.value,
desc: v.desc,
})
}
/>
</Input.Wrapper>
</Grid.Col>
<Grid.Col span={6}>
<Input.Wrapper label="Deskripsi">
<Input
value={v.desc}
onChange={(e) =>
handleEditSyarat(i, {
name: v.name,
desc: e.target.value,
})
}
/>
</Input.Wrapper>
</Grid.Col>
</Grid>
))}
</Flex>
<Group justify="center" grow>
<Button variant="light" onClick={close}>
Batal
</Button>
<Button variant="filled" onClick={handleEdit} loading={btnLoading}>
Simpan
</Button>
</Group>
</Stack>
</Modal>
{/* Modal Tambah */}
<Modal
opened={openedTambah}
onClose={closeTambah}
title={"Tambah"}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
size={"lg"}
>
<Stack gap="lg">
<Input.Wrapper label="Tambah Kategori Pelayanan Surat">
<Input
value={dataTambah.name}
onChange={(e) =>
setDataTambah({ ...dataTambah, name: e.target.value })
}
/>
</Input.Wrapper>
<TagsInput
label="Data Pelengkap"
placeholder="Tambah data pelengkap"
splitChars={[","]}
value={dataTambah.dataText}
onChange={(value) =>
setDataTambah({ ...dataTambah, dataText: value })
}
/>
<Flex direction={"column"} gap={"md"}>
<Group>
<Text size="sm" c={"white"}>
Syarat dokumen
</Text>
<Tooltip label="Tambah Syarat Dokumen">
<ActionIcon
variant="light"
size="sm"
color="blue"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => {
setDataTambah({
...dataTambah,
syaratDokumen: [
...dataTambah.syaratDokumen,
{ name: "", desc: "" },
],
});
}}
>
<IconPlus size={20} />
</ActionIcon>
</Tooltip>
</Group>
{dataTambah?.syaratDokumen?.map((v: any, index: number) => (
<Grid
key={index}
style={{
borderBottom: "1px solid gray",
paddingBottom: "10px",
}}
>
<Grid.Col
span={1}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
>
<Tooltip label="Delete Syarat Dokumen">
<ActionIcon
variant="light"
size="sm"
color="red"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => {
setDataTambah({
...dataTambah,
syaratDokumen: dataTambah.syaratDokumen.filter(
(v: any, i: number) => i !== index,
),
});
}}
disabled={dataTambah?.syaratDokumen?.length === 1}
>
<IconTrash size={20} />
</ActionIcon>
</Tooltip>
</Grid.Col>
<Grid.Col span={5}>
<Input.Wrapper label="Nama">
<Input
value={dataTambah?.syaratDokumen[index]?.name}
onChange={(e) =>
setDataTambah({
...dataTambah,
syaratDokumen: dataTambah.syaratDokumen.map(
(v: any, i: number) =>
i === index ? { ...v, name: e.target.value } : v,
),
})
}
/>
</Input.Wrapper>
</Grid.Col>
<Grid.Col span={6}>
<Input.Wrapper label="Deskripsi">
<Input
value={dataTambah?.syaratDokumen[index]?.desc}
onChange={(e) =>
setDataTambah({
...dataTambah,
syaratDokumen: dataTambah.syaratDokumen.map(
(v: any, i: number) =>
i === index ? { ...v, desc: e.target.value } : v,
),
})
}
/>
</Input.Wrapper>
</Grid.Col>
</Grid>
))}
</Flex>
<Group justify="center" grow>
<Button variant="light" onClick={closeTambah}>
Batal
</Button>
<Button
variant="filled"
onClick={handleCreate}
loading={btnLoading}
>
Simpan
</Button>
</Group>
</Stack>
</Modal>
{/* Modal Delete */}
<Modal
opened={openedDelete}
onClose={closeDelete}
title={"Delete Kategori Pelayanan Surat"}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Text size="md" color="gray.6">
Apakah anda yakin ingin menghapus kategori ini?
</Text>
<Group justify="center" grow>
<Button variant="light" onClick={closeDelete}>
Batal
</Button>
<Button
variant="filled"
color="red"
onClick={handleDelete}
loading={btnLoading}
>
Hapus
</Button>
</Group>
</Stack>
</Modal>
{/* Modal Detail */}
<Modal
opened={openedDetail}
onClose={closeDetail}
title={"Detail Kategori"}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
size={"lg"}
>
<Stack gap="sm">
<Flex direction={"column"}>
<Text size="sm" color="white">
Kategori
</Text>
<Text size="md">{dataChoose?.name ?? ""}</Text>
</Flex>
<Flex direction={"column"}>
<Text size="sm" color="white">
Syarat Dokumen
</Text>
<List>
{dataChoose?.syaratDokumen?.map((v: any) => (
<List.Item key={v.id}>{v.desc}</List.Item>
))}
</List>
</Flex>
<Flex direction={"column"}>
<Text size="sm" color="white">
Data Pelengkap
</Text>
<List>
{dataChoose?.dataText?.map((v: any) => (
<List.Item key={v.id}>{v}</List.Item>
))}
</List>
</Flex>
</Stack>
</Modal>
{/* Table */}
<Stack gap={"md"}>
<Flex align="center" justify="space-between">
<Title order={4} c="gray.2">
Kategori Pelayanan Surat
</Title>
<Tooltip label="Tambah Kategori Pelayanan Surat">
<Button
variant="light"
leftSection={<IconPlus size={20} />}
onClick={openTambah}
>
Tambah
</Button>
</Tooltip>
</Flex>
<Divider my={0} />
<Stack gap={"md"}>
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Kategori</Table.Th>
<Table.Th>Aksi</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{list?.map((v: any) => (
<Table.Tr key={v.id}>
<Table.Td>{v.name}</Table.Td>
<Table.Td>
<Group>
<Tooltip label="View Detail">
<ActionIcon
variant="light"
size="sm"
color="green"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => {
setDataChoose(v);
openDetail();
}}
>
<IconEye size={20} />
</ActionIcon>
</Tooltip>
<Tooltip label="Edit Kategori">
<ActionIcon
variant="light"
size="sm"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => {
setDataChoose(v);
open();
}}
>
<IconEdit size={20} />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete Kategori">
<ActionIcon
variant="light"
size="sm"
color="red"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => {
setDataDelete(v.id);
openDelete();
}}
>
<IconTrash size={20} />
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
</Stack>
</>
);
}

View File

@@ -0,0 +1,355 @@
import apiFetch from "@/lib/apiFetch";
import {
ActionIcon,
Button,
Divider,
Flex,
Group,
Input,
Modal,
Stack,
Table,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import { IconEdit, IconPlus, IconTrash } from "@tabler/icons-react";
import { useState } from "react";
import useSWR from "swr";
import notification from "./notificationGlobal";
export default function KategoriPengaduan() {
const [openedDelete, { open: openDelete, close: closeDelete }] =
useDisclosure(false);
const [btnDisable, setBtnDisable] = useState(true);
const [btnLoading, setBtnLoading] = useState(false);
const [opened, { open, close }] = useDisclosure(false);
const [openedTambah, { open: openTambah, close: closeTambah }] =
useDisclosure(false);
const [dataDelete, setDataDelete] = useState("");
const { data, mutate, isLoading } = useSWR("/", () =>
apiFetch.api.pengaduan.category.get(),
);
const list = data?.data?.data || [];
const [dataEdit, setDataEdit] = useState({
id: "",
name: "",
});
const [dataTambah, setDataTambah] = useState("");
useShallowEffect(() => {
mutate();
}, []);
async function handleCreate() {
try {
setBtnLoading(true);
const res = await apiFetch.api.pengaduan.category.create.post({
name: dataTambah,
});
if (res.status === 200) {
mutate();
closeTambah();
setDataTambah("");
notification({
title: "Success",
message: "Your category have been saved",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to create category",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to create category",
type: "error",
});
} finally {
setBtnLoading(false);
}
}
async function handleEdit() {
try {
setBtnLoading(true);
const res = await apiFetch.api.pengaduan.category.update.post(dataEdit);
if (res.status === 200) {
mutate();
close();
notification({
title: "Success",
message: "Your category have been saved",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to edit category",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to edit category",
type: "error",
});
} finally {
setBtnLoading(false);
}
}
function chooseEdit({
data,
}: {
data: { id: string; value: string; name: string };
}) {
setDataEdit(data);
open();
}
function onValidation({
kat,
value,
aksi,
}: {
kat: "name";
value: string;
aksi: "edit" | "tambah";
}) {
if (value.length < 1) {
setBtnDisable(true);
} else {
setBtnDisable(false);
}
if (kat === "name") {
if (aksi === "edit") {
setDataEdit({ ...dataEdit, name: value });
} else {
setDataTambah(value);
}
}
}
async function handleDelete() {
try {
setBtnLoading(true);
const res = await apiFetch.api.pengaduan.category.delete.post({
id: dataDelete,
});
if (res.status === 200) {
mutate();
closeDelete();
notification({
title: "Success",
message: "Your category have been deleted",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to delete category",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to delete category",
type: "error",
});
} finally {
setBtnLoading(false);
}
}
useShallowEffect(() => {
if (dataEdit.name.length > 0) {
setBtnDisable(false);
}
}, [dataEdit.id]);
useShallowEffect(() => {
if (dataTambah.length > 0) {
setBtnDisable(false);
}
}, [dataTambah]);
return (
<>
{/* Modal Edit */}
<Modal
opened={opened}
onClose={close}
title={"Edit"}
centered
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="ld">
<Input.Wrapper label="Edit Kategori">
<Input
value={dataEdit.name}
onChange={(e) =>
onValidation({
kat: "name",
value: e.target.value,
aksi: "edit",
})
}
/>
</Input.Wrapper>
<Group justify="center" grow>
<Button variant="light" onClick={close}>
Batal
</Button>
<Button
variant="filled"
onClick={handleEdit}
disabled={btnDisable}
loading={btnLoading}
>
Simpan
</Button>
</Group>
</Stack>
</Modal>
{/* Modal Tambah */}
<Modal
opened={openedTambah}
onClose={closeTambah}
title={"Tambah"}
centered
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="ld">
<Input.Wrapper label="Tambah Kategori">
<Input
value={dataTambah}
onChange={(e) =>
onValidation({
kat: "name",
value: e.target.value,
aksi: "tambah",
})
}
/>
</Input.Wrapper>
<Group justify="center" grow>
<Button variant="light" onClick={closeTambah}>
Batal
</Button>
<Button
variant="filled"
onClick={handleCreate}
disabled={btnDisable}
loading={btnLoading}
>
Simpan
</Button>
</Group>
</Stack>
</Modal>
{/* Modal Delete */}
<Modal
opened={openedDelete}
onClose={closeDelete}
title={"Delete"}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Text size="md" color="gray.6">
Apakah anda yakin ingin menghapus kategori ini?
</Text>
<Group justify="center" grow>
<Button variant="light" onClick={closeDelete}>
Batal
</Button>
<Button
variant="filled"
color="red"
onClick={handleDelete}
loading={btnLoading}
>
Hapus
</Button>
</Group>
</Stack>
</Modal>
<Stack gap={"md"}>
<Flex align="center" justify="space-between">
<Title order={4} c="gray.2">
Kategori Pengaduan
</Title>
<Tooltip label="Tambah Kategori Pengaduan">
<Button
variant="light"
leftSection={<IconPlus size={20} />}
onClick={openTambah}
>
Tambah
</Button>
</Tooltip>
</Flex>
<Divider my={0} />
<Stack gap={"md"}>
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Kategori</Table.Th>
<Table.Th>Aksi</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{list?.map((v: any) => (
<Table.Tr key={v.id}>
<Table.Td>{v.name}</Table.Td>
<Table.Td>
<Group>
<Tooltip label="Edit Kategori">
<ActionIcon
variant="light"
size="sm"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => chooseEdit({ data: v })}
>
<IconEdit size={20} />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete Kategori">
<ActionIcon
variant="light"
size="sm"
color="red"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => {
setDataDelete(v.id);
openDelete();
}}
>
<IconTrash size={20} />
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
</Stack>
</>
);
}

View File

@@ -0,0 +1,246 @@
import apiFetch from "@/lib/apiFetch";
import {
Button,
Divider,
Flex,
Group,
Input,
Modal,
Stack,
Title,
} from "@mantine/core";
import { useEffect, useState } from "react";
import notification from "./notificationGlobal";
export default function ProfileUser() {
const [opened, setOpened] = useState(false);
const [openedPassword, setOpenedPassword] = useState(false);
const [pwdBaru, setPwdBaru] = useState("");
const [host, setHost] = useState({
id: "",
name: "",
phone: "",
roleId: "",
email: "",
});
const [error, setError] = useState({
name: false,
email: false,
phone: false,
});
useEffect(() => {
async function fetchHost() {
const { data } = await apiFetch.api.user.find.get();
setHost({
id: data?.user?.id ?? "",
name: data?.user?.name ?? "",
phone: data?.user?.phone ?? "",
roleId: data?.user?.roleId ?? "",
email: data?.user?.email ?? "",
});
}
fetchHost();
}, []);
function onValidation({
kat,
value,
}: {
kat: "name" | "email" | "phone";
value: string;
}) {
if (value.length < 1) {
setError({ ...error, [kat]: true });
} else {
setError({ ...error, [kat]: false });
}
setHost({ ...host, [kat]: value });
}
async function handleUpdate() {
try {
const res = await apiFetch.api.user.update.post(host);
if (res.status === 200) {
setOpened(false);
notification({
title: "Success",
message: "Your profile have been saved",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to update profile",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to update profile",
type: "error",
});
}
}
async function handleUpdatePassword() {
try {
const res = await apiFetch.api.user["update-password"].post({
password: pwdBaru,
id: host.id,
});
if (res.status === 200) {
setPwdBaru("");
setOpenedPassword(false);
notification({
title: "Success",
message: "Your password have been saved",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to update password",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to update password",
type: "error",
});
}
}
return (
<>
<Stack gap={"md"}>
<Flex align="center" justify="space-between">
<Title order={4} c="gray.2">
Profile Pengguna
</Title>
<Group gap="md">
<Button variant="light" onClick={() => setOpened(true)}>
Edit
</Button>
<Button variant="light" onClick={() => setOpenedPassword(true)}>
Ubah Password
</Button>
</Group>
</Flex>
<Divider my={0} />
<Stack gap={"md"}>
<Group gap="xl" grow>
<Input.Wrapper label="Nama" description="" error="">
<Input value={host?.name ?? ""} readOnly />
</Input.Wrapper>
<Input.Wrapper label="Phone" description="" error="">
<Input value={host?.phone ?? ""} readOnly />
</Input.Wrapper>
</Group>
<Group gap="xl" grow>
<Input.Wrapper label="Email" description="" error="">
<Input value={host?.email ?? ""} readOnly />
</Input.Wrapper>
<Input.Wrapper label="Role" description="" error="">
<Input value={host?.roleId ?? ""} readOnly />
</Input.Wrapper>
</Group>
</Stack>
</Stack>
<Modal
opened={opened}
onClose={() => setOpened(false)}
title={"Edit Profile"}
size={"lg"}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap={"md"}>
<Input.Wrapper
label="Nama"
description=""
error={error.name ? "Field is required" : ""}
>
<Input
value={host?.name ?? ""}
onChange={(e) =>
onValidation({ kat: "name", value: e.target.value })
}
/>
</Input.Wrapper>
<Input.Wrapper
label="Phone"
description=""
error={error.phone ? "Field is required" : ""}
>
<Input
value={host?.phone ?? ""}
onChange={(e) =>
onValidation({ kat: "phone", value: e.target.value })
}
/>
</Input.Wrapper>
<Input.Wrapper
label="Email"
description=""
error={error.email ? "Field is required" : ""}
>
<Input
value={host?.email ?? ""}
onChange={(e) =>
onValidation({ kat: "email", value: e.target.value })
}
/>
</Input.Wrapper>
<Group grow>
<Button variant="light" onClick={() => setOpened(false)}>
Batal
</Button>
<Button
variant="filled"
onClick={() => handleUpdate()}
disabled={error.name || error.phone || error.email}
>
Simpan
</Button>
</Group>
</Stack>
</Modal>
<Modal
opened={openedPassword}
onClose={() => setOpenedPassword(false)}
title={"Ubah Password"}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap={"md"}>
<Input.Wrapper label="Password Baru" description="">
<Input
value={pwdBaru}
onChange={(e) => setPwdBaru(e.target.value)}
/>
</Input.Wrapper>
<Group grow>
<Button variant="light" onClick={() => setOpenedPassword(false)}>
Batal
</Button>
<Button
variant="filled"
onClick={() => handleUpdatePassword()}
disabled={pwdBaru.length < 1}
>
Simpan
</Button>
</Group>
</Stack>
</Modal>
</>
);
}

View File

@@ -0,0 +1,466 @@
import apiFetch from "@/lib/apiFetch";
import {
ActionIcon,
Button,
Divider,
Flex,
Group,
Input,
Modal,
Select,
Stack,
Table,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import { IconEdit, IconPlus, IconTrash } from "@tabler/icons-react";
import { useState } from "react";
import useSWR from "swr";
import notification from "./notificationGlobal";
export default function UserSetting() {
const [btnDisable, setBtnDisable] = useState(true);
const [btnLoading, setBtnLoading] = useState(false);
const [opened, { open, close }] = useDisclosure(false);
const [openedDelete, { open: openDelete, close: closeDelete }] =
useDisclosure(false);
const [dataDelete, setDataDelete] = useState("");
const {
data: dataRole,
mutate: mutateRole,
isLoading: isLoadingRole,
} = useSWR("user-role", () => apiFetch.api.user.role.get());
const [openedTambah, { open: openTambah, close: closeTambah }] =
useDisclosure(false);
const { data, mutate, isLoading } = useSWR("user-list", () =>
apiFetch.api.user.list.get(),
);
const list = data?.data || [];
const listRole = dataRole?.data || [];
const [dataEdit, setDataEdit] = useState({
id: "",
name: "",
phone: "",
email: "",
roleId: "",
});
const [dataTambah, setDataTambah] = useState({
name: "",
email: "",
roleId: "",
password: "",
phone: "",
});
const [error, setError] = useState({
name: false,
email: false,
roleId: false,
password: false,
phone: false,
});
useShallowEffect(() => {
mutate();
}, []);
async function handleCreate() {
try {
setBtnLoading(true);
const res = await apiFetch.api.user.create.post(dataTambah);
if (res.status === 200) {
mutate();
closeTambah();
setDataTambah({
name: "",
email: "",
roleId: "",
password: "",
phone: "",
});
notification({
title: "Success",
message: "Your user have been saved",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to create user ",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to create user",
type: "error",
});
} finally {
setBtnLoading(false);
}
}
async function handleEdit() {
try {
setBtnLoading(true);
const res = await apiFetch.api.pengaduan.category.update.post(dataEdit);
if (res.status === 200) {
mutate();
close();
notification({
title: "Success",
message: "Your category have been saved",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to edit category",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to edit category",
type: "error",
});
} finally {
setBtnLoading(false);
}
}
async function handleDelete() {
try {
setBtnLoading(true);
const res = await apiFetch.api.user.delete.post({ id: dataDelete });
if (res.status === 200) {
mutate();
closeDelete();
notification({
title: "Success",
message: "Your user have been deleted",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to delete user",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to delete user",
type: "error",
});
} finally {
setBtnLoading(false);
}
}
function chooseEdit({
data,
}: {
data: {
id: string;
name: string;
phone: string;
email: string;
roleId: string;
};
}) {
setDataEdit(data);
open();
}
function onValidation({
kat,
value,
aksi,
}: {
kat: "name" | "email" | "roleId" | "password" | "phone";
value: string | null;
aksi: "edit" | "tambah";
}) {
if (value == null || value.length < 1) {
setBtnDisable(true);
setError({ ...error, [kat]: true });
} else {
setBtnDisable(false);
setError({ ...error, [kat]: false });
}
if (aksi === "edit") {
setDataEdit({ ...dataEdit, [kat]: value });
} else {
setDataTambah({ ...dataTambah, [kat]: value });
}
}
useShallowEffect(() => {
if (dataEdit.name.length > 0) {
setBtnDisable(false);
}
}, [dataEdit.id]);
return (
<>
{/* Modal Edit */}
<Modal
opened={opened}
onClose={close}
title={"Edit"}
centered
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="ld">
<Input.Wrapper label="Edit Kategori">
<Input
value={dataEdit.name}
onChange={(e) =>
onValidation({
kat: "name",
value: e.target.value,
aksi: "edit",
})
}
/>
</Input.Wrapper>
<Group justify="center" grow>
<Button variant="light" onClick={close}>
Batal
</Button>
<Button
variant="filled"
onClick={handleEdit}
disabled={btnDisable}
loading={btnLoading}
>
Simpan
</Button>
</Group>
</Stack>
</Modal>
{/* Modal Tambah */}
<Modal
opened={openedTambah}
onClose={closeTambah}
title={"Tambah"}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="ld">
<Input.Wrapper
label="Nama"
description=""
error={error.name ? "Field is required" : ""}
>
<Input
value={dataTambah.name}
onChange={(e) =>
onValidation({
kat: "name",
value: e.target.value,
aksi: "tambah",
})
}
/>
</Input.Wrapper>
<Select
label="Role"
placeholder="Pilih Role"
data={listRole.map((r: any) => ({
value: r.id,
label: r.name,
}))}
value={dataTambah.roleId || null}
error={error.roleId ? "Field is required" : ""}
onChange={(_value, option) => {
onValidation({
kat: "roleId",
value: option?.value,
aksi: "tambah",
});
}}
/>
<Input.Wrapper label="Phone" description="">
<Input
value={dataTambah.phone}
onChange={(e) =>
onValidation({
kat: "phone",
value: e.target.value,
aksi: "tambah",
})
}
/>
</Input.Wrapper>
<Input.Wrapper
label="Email"
description=""
error={error.email ? "Field is required" : ""}
>
<Input
value={dataTambah.email}
onChange={(e) =>
onValidation({
kat: "email",
value: e.target.value,
aksi: "tambah",
})
}
/>
</Input.Wrapper>
<Input.Wrapper
label="Password"
description=""
error={error.password ? "Field is required" : ""}
>
<Input
value={dataTambah.password}
onChange={(e) =>
onValidation({
kat: "password",
value: e.target.value,
aksi: "tambah",
})
}
/>
</Input.Wrapper>
<Group justify="center" grow>
<Button variant="light" onClick={closeTambah}>
Batal
</Button>
<Button
variant="filled"
onClick={handleCreate}
disabled={
btnDisable ||
dataTambah.name.length < 1 ||
dataTambah.email.length < 1 ||
dataTambah.password.length < 1 ||
dataTambah.roleId.length < 1 ||
dataTambah.phone.length < 1
}
loading={btnLoading}
>
Simpan
</Button>
</Group>
</Stack>
</Modal>
{/* Modal Delete */}
<Modal
opened={openedDelete}
onClose={closeDelete}
title={"Delete"}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Text size="md" color="gray.6">
Apakah anda yakin ingin menghapus user ini?
</Text>
<Group justify="center" grow>
<Button variant="light" onClick={closeDelete}>
Batal
</Button>
<Button
variant="filled"
color="red"
onClick={handleDelete}
loading={btnLoading}
>
Hapus
</Button>
</Group>
</Stack>
</Modal>
<Stack gap={"md"}>
<Flex align="center" justify="space-between">
<Title order={4} c="gray.2">
Daftar User
</Title>
<Tooltip label="Tambah User">
<Button
variant="light"
leftSection={<IconPlus size={20} />}
onClick={openTambah}
>
Tambah
</Button>
</Tooltip>
</Flex>
<Divider my={0} />
<Stack gap={"md"}>
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Nama</Table.Th>
<Table.Th>Telepon</Table.Th>
<Table.Th>Email</Table.Th>
<Table.Th>Role</Table.Th>
<Table.Th>Aksi</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{list.length > 0 ? (
list?.map((v: any) => (
<Table.Tr key={v.id}>
<Table.Td>{v.name}</Table.Td>
<Table.Td>{v.phone}</Table.Td>
<Table.Td>{v.email}</Table.Td>
<Table.Td>{v.roleId}</Table.Td>
<Table.Td>
<Group>
<Tooltip label="Edit User">
<ActionIcon
variant="light"
size="sm"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => chooseEdit({ data: v })}
>
<IconEdit size={20} />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete User">
<ActionIcon
variant="light"
size="sm"
color="red"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => {
setDataDelete(v.id);
openDelete();
}}
>
<IconTrash size={20} />
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
))
) : (
<Table.Tr>
<Table.Td colSpan={5} align="center">
Data User Tidak Ditemukan
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Stack>
</Stack>
</>
);
}

View File

@@ -0,0 +1,38 @@
import { showNotification } from "@mantine/notifications";
export default function notification({ title, message, type }: { title: string, message: string, type: "success" | "error" | "warning" | "info" }) {
switch (type) {
case "success":
return showNotification({
title,
message,
color: "green",
autoClose: 3000,
})
break;
case "error":
return showNotification({
title,
message,
color: "red",
autoClose: 3000,
})
break;
case "warning":
return showNotification({
title,
message,
color: "orange",
autoClose: 3000,
})
break;
case "info":
return showNotification({
title,
message,
color: "blue",
autoClose: 3000,
})
break;
}
}

View File

@@ -1,3 +1,4 @@
import cors from "@elysiajs/cors";
import Swagger from "@elysiajs/swagger";
import Elysia from "elysia";
import html from "./index.html";
@@ -6,15 +7,16 @@ import { apiAuth } from "./server/middlewares/apiAuth";
import AduanRoute from "./server/routes/aduan_route";
import ApiKeyRoute from "./server/routes/apikey_route";
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 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 TestRoute from "./server/routes/test";
import TestPengaduanRoute from "./server/routes/test_pengaduan";
import UserRoute from "./server/routes/user_route";
import cors from "@elysiajs/cors";
import WargaRoute from "./server/routes/warga_route";
const Docs = new Elysia({
tags: ["docs"],
@@ -30,7 +32,9 @@ const Api = new Elysia({
})
.use(PengaduanRoute)
.use(PelayananRoute)
.use(TestRoute)
.use(ConfigurationDesaRoute)
.use(WargaRoute)
.use(TestPengaduanRoute)
.use(apiAuth)
.use(ApiKeyRoute)
.use(DarmasabaRoute)

View File

@@ -236,13 +236,13 @@ function NavigationDashboard() {
description: "Manage pelayanan surat",
},
{
path: "/scr/dashboard/user",
path: "/scr/dashboard/warga/list-warga",
icon: <IconUsersGroup size={20} />,
label: "User",
description: "Manage user",
label: "Warga",
description: "Manage warga",
},
{
path: "/scr/dashboard/setting",
path: "/scr/dashboard/setting/detail-setting",
icon: <IconSettings size={20} />,
label: "Setting",
description:

View File

@@ -0,0 +1,370 @@
import apiFetch from "@/lib/apiFetch";
import {
Anchor,
Badge,
Button,
Card,
Container,
Divider,
Flex,
Grid,
Group,
Modal,
Stack,
Table,
Text,
Textarea,
Title,
} from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import {
IconAlignJustified,
IconCategory,
IconFileCertificate,
IconInfoTriangle,
IconMapPin,
IconMessageReport,
IconPhotoScan,
IconUser,
} from "@tabler/icons-react";
import { useState } from "react";
import { useLocation } from "react-router-dom";
import useSwr from "swr";
export default function DetailPelayananPage() {
const { search } = useLocation();
const query = new URLSearchParams(search);
const id = query.get("id");
return (
<Container size="xl" py="xl" w={"100%"}>
<Grid>
<Grid.Col span={8}>
<Stack gap={"xl"}>
<DetailDataPelayanan />
<DetailDataHistori />
</Stack>
</Grid.Col>
<Grid.Col span={4}>
<DetailUserPelayanan />
</Grid.Col>
</Grid>
</Container>
);
}
function DetailDataPelayanan() {
const [opened, { open, close }] = useDisclosure(false);
const [catModal, setCatModal] = useState<"tolak" | "terima">("tolak");
return (
<>
<Modal
opened={opened}
onClose={close}
title={"Konfirmasi"}
centered
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="sm">
{catModal === "tolak" ? (
<>
<Text>
Anda yakin ingin menolak pengaduan ini? Berikan alasan penolakan
</Text>
<Textarea size="md" minRows={5} />
<Group justify="center" grow>
<Button variant="light" onClick={close}>
Batal
</Button>
<Button variant="filled" color="red" onClick={close}>
Tolak
</Button>
</Group>
</>
) : (
<>
<Text>Anda yakin ingin menerima pengaduan ini?</Text>
<Group justify="center" grow>
<Button variant="light" onClick={close}>
Batal
</Button>
<Button variant="filled" color="green" onClick={close}>
Terima
</Button>
</Group>
</>
)}
</Stack>
</Modal>
<Card
radius="md"
p="lg"
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 20px rgba(0,255,200,0.08)",
}}
>
<Stack gap={"md"}>
<Flex align="center" justify="space-between">
<Group gap="xs">
<Title order={4} c="gray.2">
Pelayanan Surat
</Title>
<Title order={4} c="dimmed">
#PGf-2345-33
</Title>
</Group>
<Badge
size="xl"
variant="light"
radius="sm"
color={"yellow"}
style={{ textTransform: "none" }}
>
antrian
</Badge>
</Flex>
<Divider my={0} />
<Grid>
<Grid.Col span={6}>
<Stack gap="md">
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconAlignJustified size={20} />
<Text size="md">Judul</Text>
</Group>
<Text size="md" c={"white"}>
Judul Pelayanan Surat
</Text>
</Flex>
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconMapPin size={20} />
<Text size="md">Lokasi</Text>
</Group>
<Text size="md" c="white">
fwef
</Text>
</Flex>
</Stack>
</Grid.Col>
<Grid.Col span={6}>
<Stack gap="md">
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconCategory size={20} />
<Text size="md">Kategori</Text>
</Group>
<Text size="md" c="white">
fwef
</Text>
</Flex>
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconPhotoScan size={20} />
<Text size="md">Gambar</Text>
</Group>
<Anchor href="https://mantine.dev/" target="_blank">
Lihat Gambar
</Anchor>
</Flex>
</Stack>
</Grid.Col>
<Grid.Col span={12}>
<Stack gap="md">
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconAlignJustified size={20} />
<Text size="md">Detail</Text>
</Group>
<Text size="md" c="white">
Lorem ipsum dolor sit, amet consectetur adipisicing elit.
Illum, corporis iusto. Suscipit veritatis quas, non nobis
fuga, laudantium accusantium tempora sint aliquid architecto
totam esse eum excepturi nostrum fugiat ut.
</Text>
</Flex>
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconInfoTriangle size={20} />
<Text size="md">Keterangan</Text>
</Group>
<Text size="md" c={"white"}>
Lorem ipsum dolor, sit amet consectetur adipisicing elit. At
fugiat eligendi nesciunt dolore? Maiores a cumque vitae
suscipit incidunt quos beatae modi, vel, id ullam quae
voluptas, deserunt quas placeat.
</Text>
</Flex>
</Stack>
</Grid.Col>
<Grid.Col span={12}>
<Group justify="center" grow>
<Button
variant="light"
onClick={() => {
setCatModal("tolak");
open();
}}
>
Tolak
</Button>
<Button
variant="filled"
onClick={() => {
setCatModal("terima");
open();
}}
>
Terima
</Button>
</Group>
</Grid.Col>
</Grid>
</Stack>
</Card>
</>
);
}
function DetailDataHistori() {
const elements = [
{ position: 6, mass: 12.011, symbol: "C", name: "Carbon" },
{ position: 7, mass: 14.007, symbol: "N", name: "Nitrogen" },
{ position: 39, mass: 88.906, symbol: "Y", name: "Yttrium" },
{ position: 56, mass: 137.33, symbol: "Ba", name: "Barium" },
{ position: 58, mass: 140.12, symbol: "Ce", name: "Cerium" },
];
const rows = elements.map((element) => (
<Table.Tr key={element.name}>
<Table.Td>{element.position}</Table.Td>
<Table.Td>{element.name}</Table.Td>
<Table.Td>{element.symbol}</Table.Td>
<Table.Td>{element.mass}</Table.Td>
</Table.Tr>
));
return (
<Card
radius="md"
p="lg"
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 20px rgba(0,255,200,0.08)",
}}
>
<Stack gap="md">
<Flex align="center" justify="space-between">
<Title order={4} c="gray.2">
Histori Pengaduan
</Title>
</Flex>
<Divider my={0} />
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Tanggal</Table.Th>
<Table.Th>Deskripsi</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>User</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
</Stack>
</Card>
);
}
function DetailUserPelayanan() {
const [page, setPage] = useState(1);
const [value, setValue] = useState("");
const { data, mutate, isLoading } = useSwr("/", () =>
apiFetch.api.pengaduan.list.get({
query: {
status,
search: value,
take: "",
page: "",
},
}),
);
useShallowEffect(() => {
mutate();
}, [status, value]);
const list = data?.data || [];
return (
<Card
radius="md"
p="lg"
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 20px rgba(0,255,200,0.08)",
}}
>
<Stack gap="md">
<Flex align="center" justify="space-between">
<Flex direction={"column"}>
<Title order={4} c="gray.2">
Warga
</Title>
</Flex>
</Flex>
<Divider my={0} />
<Stack gap="md">
<Group justify="space-between">
<Group gap="xs">
<IconUser size={20} />
<Text size="md">Nama</Text>
</Group>
<Text size="md" c={"white"}>
Amalia Dwi Yustiani
</Text>
</Group>
<Group justify="space-between">
<Group gap="xs">
<IconMapPin size={20} />
<Text size="md">Telepon</Text>
</Group>
<Text size="md" c="white">
08123456789
</Text>
</Group>
<Group justify="space-between">
<Group gap="xs">
<IconMessageReport size={20} />
<Text size="md">Jumlah Pengaduan</Text>
</Group>
<Text size="md" c="white">
10
</Text>
</Group>
<Group justify="space-between">
<Group gap="xs">
<IconFileCertificate size={20} />
<Text size="md">Jumlah Pelayanan Surat</Text>
</Group>
<Text size="md" c="white">
10
</Text>
</Group>
</Stack>
</Stack>
</Card>
);
}

View File

@@ -133,6 +133,8 @@ function ListPelayananSurat({ status }: { status: StatusKey }) {
mutate();
}, [status, value]);
const navigate = useNavigate();
if (isLoading)
return (
<Card
@@ -169,22 +171,18 @@ function ListPelayananSurat({ status }: { status: StatusKey }) {
/>
}
/>
{/* <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 ? (
{list?.length === 0 ? (
<Flex justify="center" align="center" py={"xl"}>
<Stack gap={4} align="center">
<IconFileSad size={32} color="gray" />
<Text c="dimmed" size="sm">
No pengaduan have been added yet.
No pelayanan surat have been added yet.
</Text>
</Stack>
</Flex>
) : (
list.map((v: any) => (
list?.map((v: any) => (
<Card
key={v.id}
radius="lg"
@@ -196,6 +194,11 @@ function ListPelayananSurat({ status }: { status: StatusKey }) {
borderColor: "rgba(100,100,100,0.2)",
boxShadow: "0 0 20px rgba(0,255,200,0.08)",
}}
onClick={() => {
navigate(
`/scr/dashboard/pelayanan-surat/detail-pelayanan?id=${v.id}`,
);
}}
>
<Stack gap="md">
<Flex align="center" justify="space-between">

View File

@@ -1,301 +1,458 @@
import notification from "@/components/notificationGlobal";
import apiFetch from "@/lib/apiFetch";
import {
Anchor,
Badge,
Card,
Container,
Divider,
Flex,
Grid,
Group,
Stack,
Table,
Text,
Title
Anchor,
Badge,
Button,
Card,
Container,
Divider,
Flex,
Grid,
Group,
Image,
Modal,
Stack,
Table,
Text,
Textarea,
Title,
} from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks";
import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import {
IconAlignJustified,
IconCategory,
IconFileCertificate,
IconInfoTriangle,
IconMapPin,
IconMessageReport,
IconPhotoScan,
IconUser
IconAlignJustified,
IconCategory,
IconFileCertificate,
IconInfoTriangle,
IconMapPin,
IconMessageReport,
IconPhone,
IconPhotoScan,
IconUser,
} from "@tabler/icons-react";
import { useState } from "react";
import type { User } from "generated/prisma";
import _ from "lodash";
import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import useSwr from "swr";
export default function DetailPengaduanPage() {
const { search } = useLocation();
const query = new URLSearchParams(search);
const id = query.get("id");
const { search } = useLocation();
const query = new URLSearchParams(search);
const id = query.get("id");
const { data, mutate, isLoading } = useSwr("/", () =>
apiFetch.api.pengaduan.detail.get({
query: {
id: id!,
},
}),
);
return (
<Container size="xl" py="xl" w={"100%"}>
<Grid>
<Grid.Col span={8}>
<Stack gap={"xl"}>
<DetailDataPengaduan />
<DetailDataHistori />
</Stack>
useShallowEffect(() => {
mutate();
}, []);
return (
<Container size="xl" py="xl" w={"100%"}>
<Grid>
<Grid.Col span={8}>
<Stack gap={"xl"}>
<DetailDataPengaduan data={data?.data?.pengaduan} onAction={() => { mutate(); }} />
<DetailDataHistori data={data?.data?.history} />
</Stack>
</Grid.Col>
<Grid.Col span={4}>
<DetailUserPengaduan data={data?.data?.warga} />
</Grid.Col>
</Grid>
</Container>
);
}
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 [keterangan, setKeterangan] = useState("");
const [host, setHost] = useState<User | null>(null);
useEffect(() => {
async function fetchHost() {
const { data } = await apiFetch.api.user.find.get();
setHost(data?.user ?? null);
}
fetchHost();
}, []);
const handleKonfirmasi = async (cat: "terima" | "tolak") => {
try {
const res = await apiFetch.api.pengaduan["update-status"].post({
id: data?.id,
status: cat == 'tolak' ? 'ditolak' : data.status == 'antrian' ? 'diterima' : data.status == 'diterima' ? 'dikerjakan' : 'selesai',
keterangan: keterangan,
idUser: host?.id ?? ""
});
if (res?.status === 200) {
onAction();
close();
notification({
title: "Success",
message: "Success update pengaduan",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to update pengaduan",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to update pengaduan",
type: "error",
});
}
}
return (
<>
{/* MODAL KONFIRMASI */}
<Modal
opened={opened}
onClose={close}
title={"Konfirmasi"}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="sm">
{catModal === "tolak" ? (
<>
<Text>
Anda yakin ingin menolak pengaduan ini? Berikan alasan penolakan
</Text>
<Textarea size="md" minRows={5} value={keterangan} onChange={(e) => setKeterangan(e.target.value)} />
<Group justify="center" grow>
<Button variant="light" onClick={close}>
Batal
</Button>
<Button variant="filled" color="red" disabled={keterangan.length < 1} onClick={() => handleKonfirmasi("tolak")}>
Tolak
</Button>
</Group>
</>
) : (
<>
<Text>Anda yakin ingin {data?.status == 'antrian' ? 'menerima' : data.status == 'diterima' ? 'mengerjakan' : 'menyelesaikan'} pengaduan ini?</Text>
<Group justify="center" grow>
<Button variant="light" onClick={close}>
Tidak
</Button>
<Button variant="filled" color="green" onClick={() => handleKonfirmasi("terima")}>
Ya
</Button>
</Group>
</>
)}
</Stack>
</Modal>
{/* MODAL GAMBAR */}
<Modal
opened={openedModalImage}
onClose={closeModalImage}
title="Gambar Pengaduan"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Image src={imageSrc!} />
</Modal>
<Card
radius="md"
p="lg"
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 20px rgba(0,255,200,0.08)",
}}
>
<Stack gap={"md"}>
<Flex align="center" justify="space-between">
<Group gap="xs">
<Title order={4} c="gray.2">
Pengaduan
</Title>
<Title order={4} c="dimmed">
#{data?.noPengaduan}
</Title>
</Group>
<Badge
size="xl"
variant="light"
radius="sm"
color={
data?.status === "diterima"
? "green"
: data?.status === "ditolak"
? "red"
: data?.status === "selesai"
? "blue"
: data?.status === "dikerjakan"
? "gray"
: "yellow"
}
style={{ textTransform: "none" }}
>
{data?.status}
</Badge>
</Flex>
<Divider my={0} />
<Grid>
<Grid.Col span={6}>
<Stack gap="md">
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconAlignJustified size={20} />
<Text size="md">Judul</Text>
</Group>
<Text size="md" c={"white"}>
{_.upperFirst(data?.title)}
</Text>
</Flex>
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconMapPin size={20} />
<Text size="md">Lokasi</Text>
</Group>
<Text size="md" c="white">
{_.upperFirst(data?.location)}
</Text>
</Flex>
</Stack>
</Grid.Col>
<Grid.Col span={4}>
<DetailUserPengaduan />
<Grid.Col span={6}>
<Stack gap="md">
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconCategory size={20} />
<Text size="md">Kategori</Text>
</Group>
<Text size="md" c="white">
{_.upperFirst(data?.category)}
</Text>
</Flex>
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconPhotoScan size={20} />
<Text size="md">Gambar</Text>
</Group>
<Anchor href="#" onClick={() => { }}>
Lihat Gambar
</Anchor>
</Flex>
</Stack>
</Grid.Col>
</Grid>
</Container>
);
}
function DetailDataPengaduan() {
return (
<Card
radius="md"
p="lg"
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 20px rgba(0,255,200,0.08)",
}}
>
<Stack gap={"md"}>
<Flex align="center" justify="space-between">
<Group gap="xs">
<Title order={4} c="gray.2">
Pengaduan
</Title>
<Title order={4} c="dimmed">
#PGf-2345-33
</Title>
</Group>
<Badge
size="xl"
variant="light"
radius="sm"
color={"yellow"}
style={{ textTransform: "none" }}
>
antrian
</Badge>
</Flex>
<Divider my={0} />
<Grid>
<Grid.Col span={6}>
<Stack gap="md">
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconAlignJustified size={20} />
<Text size="md">
Judul
</Text>
</Group>
<Text size="md" c={"white"}>Judul Pengaduan</Text>
</Flex>
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconMapPin size={20} />
<Text size="md">
Lokasi
</Text>
</Group>
<Text size="md" c="white">fwef</Text>
</Flex>
</Stack>
</Grid.Col>
<Grid.Col span={6}>
<Stack gap="md">
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconCategory size={20} />
<Text size="md">
Kategori
</Text>
</Group>
<Text size="md" c="white">fwef</Text>
</Flex>
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconPhotoScan size={20} />
<Text size="md">
Gambar
</Text>
</Group>
<Anchor href="https://mantine.dev/" target="_blank">
Lihat Gambar
</Anchor>
</Flex>
</Stack>
</Grid.Col>
<Grid.Col span={12}>
<Stack gap="md">
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconAlignJustified size={20} />
<Text size="md">
Detail
</Text>
</Group>
<Text size="md" c="white">
Lorem ipsum dolor sit, amet consectetur adipisicing elit. Illum, corporis iusto. Suscipit veritatis quas, non nobis fuga, laudantium accusantium tempora sint aliquid architecto totam esse eum excepturi nostrum fugiat ut.
</Text>
</Flex>
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconInfoTriangle size={20} />
<Text size="md">
Keterangan
</Text>
</Group>
<Text size="md" c={"white"}>
Lorem ipsum dolor, sit amet consectetur adipisicing elit. At fugiat eligendi nesciunt dolore? Maiores a cumque vitae suscipit incidunt quos beatae modi, vel, id ullam quae voluptas, deserunt quas placeat.
</Text>
</Flex>
</Stack>
</Grid.Col>
</Grid>
</Stack>
<Grid.Col span={12}>
<Stack gap="md">
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconAlignJustified size={20} />
<Text size="md">Detail</Text>
</Group>
<Text size="md" c="white">
{_.upperFirst(data?.detail)}
</Text>
</Flex>
{
data?.keterangan && (
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconInfoTriangle size={20} />
<Text size="md">Keterangan</Text>
</Group>
<Text size="md" c={"white"}>
{_.upperFirst(data?.keterangan)}
</Text>
</Flex>
)
}
</Stack>
</Grid.Col>
<Grid.Col span={12}>
{
data?.status === "antrian" ? (
<Group justify="center" grow>
<Button
variant="light"
onClick={() => {
setCatModal("tolak");
open();
}}
>
Tolak
</Button>
<Button
variant="filled"
onClick={() => {
setCatModal("terima");
open();
}}
>
Terima
</Button>
</Group>
) : data?.status === "diterima" ? (
<Group justify="center" grow>
<Button
variant="filled"
onClick={() => {
setCatModal("terima");
open();
}}
>
Kerjakan
</Button>
</Group>
) : data?.status === "dikerjakan" ? (
<Group justify="center" grow>
<Button
variant="filled"
onClick={() => {
setCatModal("terima");
open();
}}
>
Selesai
</Button>
</Group>
) : <></>
}
</Grid.Col>
</Grid>
</Stack>
</Card>
);
</>
);
}
function DetailDataHistori() {
const elements = [
{ position: 6, mass: 12.011, symbol: 'C', name: 'Carbon' },
{ position: 7, mass: 14.007, symbol: 'N', name: 'Nitrogen' },
{ position: 39, mass: 88.906, symbol: 'Y', name: 'Yttrium' },
{ position: 56, mass: 137.33, symbol: 'Ba', name: 'Barium' },
{ position: 58, mass: 140.12, symbol: 'Ce', name: 'Cerium' },
];
const rows = elements.map((element) => (
<Table.Tr key={element.name}>
<Table.Td>{element.position}</Table.Td>
<Table.Td>{element.name}</Table.Td>
<Table.Td>{element.symbol}</Table.Td>
<Table.Td>{element.mass}</Table.Td>
</Table.Tr>
));
return (
<Card
radius="md"
p="lg"
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 20px rgba(0,255,200,0.08)",
}}
>
<Stack gap="md">
<Flex align="center" justify="space-between">
<Title order={4} c="gray.2">
Histori Pengaduan
</Title>
</Flex>
<Divider my={0} />
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Tanggal</Table.Th>
<Table.Th>Deskripsi</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>User</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
</Stack>
</Card>
)
function DetailDataHistori({ data }: { data: any }) {
return (
<Card
radius="md"
p="lg"
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 20px rgba(0,255,200,0.08)",
}}
>
<Stack gap="md">
<Flex align="center" justify="space-between">
<Title order={4} c="gray.2">
Histori Pengaduan
</Title>
</Flex>
<Divider my={0} />
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Tanggal</Table.Th>
<Table.Th>Deskripsi</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>User</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{
data?.map((item: any) => (
<Table.Tr key={item.id}>
<Table.Td style={{ whiteSpace: "nowrap" }}>{item.createdAt}</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>
</Table.Tr>
))
}
</Table.Tbody>
</Table>
</Stack>
</Card>
);
}
function DetailUserPengaduan() {
const [page, setPage] = useState(1);
const [value, setValue] = useState("");
const { data, mutate, isLoading } = useSwr("/", () =>
apiFetch.api.pengaduan.list.get({
query: {
status,
search: value,
take: "",
page: "",
},
}),
);
useShallowEffect(() => {
mutate();
}, [status, value]);
const list = data?.data || [];
return (
<Card
radius="md"
p="lg"
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 20px rgba(0,255,200,0.08)",
}}
>
<Stack gap="md">
<Flex align="center" justify="space-between">
<Flex direction={"column"}>
<Title order={4} c="gray.2">
Warga
</Title>
</Flex>
</Flex>
<Divider my={0} />
<Stack gap="md">
<Group justify="space-between" >
<Group gap="xs">
<IconUser size={20} />
<Text size="md">
Nama
</Text>
</Group>
<Text size="md" c={"white"}>Amalia Dwi Yustiani</Text>
</Group>
<Group justify="space-between" >
<Group gap="xs">
<IconMapPin size={20} />
<Text size="md">
Telepon
</Text>
</Group>
<Text size="md" c="white">08123456789</Text>
</Group>
<Group justify="space-between" >
<Group gap="xs">
<IconMessageReport size={20} />
<Text size="md">
Jumlah Pengaduan
</Text>
</Group>
<Text size="md" c="white">10</Text>
</Group>
<Group justify="space-between" >
<Group gap="xs">
<IconFileCertificate size={20} />
<Text size="md">
Jumlah Pelayanan Surat
</Text>
</Group>
<Text size="md" c="white">10</Text>
</Group>
</Stack>
</Stack>
</Card>
);
function DetailUserPengaduan({ data }: { data: any }) {
return (
<Card
radius="md"
p="lg"
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 20px rgba(0,255,200,0.08)",
}}
>
<Stack gap="md">
<Flex align="center" justify="space-between">
<Flex direction={"column"}>
<Title order={4} c="gray.2">
Warga
</Title>
</Flex>
</Flex>
<Divider my={0} />
<Stack gap="md">
<Group justify="space-between">
<Group gap="xs">
<IconUser size={20} />
<Text size="md">Nama</Text>
</Group>
<Text size="md" c={"white"}>
{data?.name}
</Text>
</Group>
<Group justify="space-between">
<Group gap="xs">
<IconPhone size={20} />
<Text size="md">Telepon</Text>
</Group>
<Text size="md" c="white">
{data?.phone}
</Text>
</Group>
<Group justify="space-between">
<Group gap="xs">
<IconMessageReport size={20} />
<Text size="md">Jumlah Pengaduan</Text>
</Group>
<Text size="md" c="white">
{data?.pengaduan}
</Text>
</Group>
<Group justify="space-between">
<Group gap="xs">
<IconFileCertificate size={20} />
<Text size="md">Jumlah Pelayanan Surat</Text>
</Group>
<Text size="md" c="white">
{data?.pelayanan}
</Text>
</Group>
</Stack>
</Stack>
</Card>
);
}

View File

@@ -24,7 +24,7 @@ import {
import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import useSwr from "swr";
import { proxy } from "valtio";
import { proxy, subscribe } from "valtio";
const state = proxy({ reload: "" });
function reloadState() {
@@ -48,20 +48,24 @@ export default function PengaduanListPage() {
function TabListPengaduan({ status }: { status: string }) {
const navigate = useNavigate();
const dataCount = useSwr("/pengaduan/count", () =>
const { data, mutate, isLoading } = useSwr("/pengaduan/count", () =>
apiFetch.api.pengaduan.count.get().then((res) => res.data),
);
useShallowEffect(() => {
mutate();
}, []);
return (
<Tabs defaultValue={status || "semua"} color="teal">
<Tabs.List grow>
<Tabs.Tab
value="all"
value="semua"
onClick={() => {
navigate("?status=semua");
}}
>
Semua ({dataCount?.data?.semua || 0})
Semua ({data?.semua || 0})
</Tabs.Tab>
<Tabs.Tab
value="antrian"
@@ -69,7 +73,7 @@ function TabListPengaduan({ status }: { status: string }) {
navigate("?status=antrian");
}}
>
Antrian ({dataCount?.data?.antrian || 0})
Antrian ({data?.antrian || 0})
</Tabs.Tab>
<Tabs.Tab
value="diterima"
@@ -77,7 +81,7 @@ function TabListPengaduan({ status }: { status: string }) {
navigate("?status=diterima");
}}
>
Diterima ({dataCount?.data?.diterima || 0})
Diterima ({data?.diterima || 0})
</Tabs.Tab>
<Tabs.Tab
value="dikerjakan"
@@ -85,7 +89,7 @@ function TabListPengaduan({ status }: { status: string }) {
navigate("?status=dikerjakan");
}}
>
Dikerjakan ({dataCount?.data?.dikerjakan || 0})
Dikerjakan ({data?.dikerjakan || 0})
</Tabs.Tab>
<Tabs.Tab
value="selesai"
@@ -93,7 +97,7 @@ function TabListPengaduan({ status }: { status: string }) {
navigate("?status=selesai");
}}
>
Selesai ({dataCount?.data?.selesai || 0})
Selesai ({data?.selesai || 0})
</Tabs.Tab>
<Tabs.Tab
value="ditolak"
@@ -101,7 +105,7 @@ function TabListPengaduan({ status }: { status: string }) {
navigate("?status=ditolak");
}}
>
Ditolak ({dataCount?.data?.ditolak || 0})
Ditolak ({data?.ditolak || 0})
</Tabs.Tab>
</Tabs.List>
</Tabs>
@@ -120,21 +124,28 @@ function ListPengaduan({ status }: { status: StatusKey }) {
const navigate = useNavigate();
const [page, setPage] = useState(1);
const [value, setValue] = useState("");
const { data, mutate, isLoading } = useSwr("/", () =>
apiFetch.api.pengaduan.list.get({
const { data, mutate, isLoading } = useSwr("/", async () => {
const res = await apiFetch.api.pengaduan.list.get({
query: {
status,
search: value,
take: "",
page: "",
},
}),
);
});
return Array.isArray(res?.data) ? res.data : []; // ⬅ paksa return array
});
useShallowEffect(() => {
mutate();
}, [status, value]);
useShallowEffect(() => {
const unsubscribe = subscribe(state, () => mutate());
return () => unsubscribe();
}, []);
if (isLoading)
return (
<Card
@@ -152,7 +163,7 @@ function ListPengaduan({ status }: { status: StatusKey }) {
</Card>
);
const list = data?.data || [];
const list = data || [];
return (
<Stack gap="xl">
@@ -186,7 +197,7 @@ function ListPengaduan({ status }: { status: StatusKey }) {
</Stack>
</Flex>
) : (
list.map((v: any) => (
Array.isArray(list) && list?.map((v: any) => (
<Card
key={v.id}
radius="lg"
@@ -198,7 +209,9 @@ function ListPengaduan({ status }: { status: StatusKey }) {
borderColor: "rgba(100,100,100,0.2)",
boxShadow: "0 0 20px rgba(0,255,200,0.08)",
}}
onClick={() => navigate(`/scr/dashboard/pengaduan/detail?id=${v.id}`)}
onClick={() =>
navigate(`/scr/dashboard/pengaduan/detail?id=${v.id}`)
}
>
<Stack gap="md">
<Flex align="center" justify="space-between">
@@ -227,7 +240,7 @@ function ListPengaduan({ status }: { status: StatusKey }) {
: v.status === "selesai"
? "blue"
: v.status === "dikerjakan"
? "purple"
? "gray"
: "yellow"
}
style={{ textTransform: "none" }}

View File

@@ -0,0 +1,107 @@
import DesaSetting from "@/components/DesaSetting";
import KategoriPelayananSurat from "@/components/KategoriPelayananSurat";
import KategoriPengaduan from "@/components/KategoriPengaduan";
import ProfileUser from "@/components/ProfileUser";
import UserSetting from "@/components/UserSetting";
import {
Button,
Card,
Container,
Divider,
Flex,
Grid,
NavLink,
Stack,
Table,
Title,
} from "@mantine/core";
import {
IconBuildingBank,
IconCategory2,
IconMailSpark,
IconUserCog,
IconUsersGroup,
} from "@tabler/icons-react";
import { useLocation } from "react-router-dom";
export default function DetailSettingPage() {
const { search } = useLocation();
const query = new URLSearchParams(search);
const type = query.get("type");
return (
<Container size="xl" py="xl" w={"100%"}>
<Grid>
<Grid.Col span={3}>
<Card
radius="md"
p="lg"
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 20px rgba(0,255,200,0.08)",
}}
>
<NavLink
href={`?type=profile`}
label="Profile"
leftSection={<IconUserCog size={16} stroke={1.5} />}
active={type === "profile" || !type}
/>
<NavLink
href={`?type=user`}
label="User"
leftSection={<IconUsersGroup size={16} stroke={1.5} />}
active={type === "user"}
/>
<NavLink
href={`?type=cat-pengaduan`}
label="Kategori Pengaduan"
leftSection={<IconCategory2 size={16} stroke={1.5} />}
active={type === "cat-pengaduan"}
/>
<NavLink
href={`?type=cat-pelayanan`}
label="Kategori Pelayanan Surat"
leftSection={<IconMailSpark size={16} stroke={1.5} />}
active={type === "cat-pelayanan"}
/>
<NavLink
href={`?type=desa`}
label="Desa"
leftSection={<IconBuildingBank size={16} stroke={1.5} />}
active={type === "desa"}
/>
</Card>
</Grid.Col>
<Grid.Col span={9}>
<Card
radius="md"
p="lg"
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 20px rgba(0,255,200,0.08)",
}}
>
{type === "cat-pengaduan" ? (
<KategoriPengaduan />
) : type === "cat-pelayanan" ? (
<KategoriPelayananSurat />
) : type === "desa" ? (
<DesaSetting />
) : type === "user" ? (
<UserSetting />
) : (
<ProfileUser />
)}
</Card>
</Grid.Col>
</Grid>
</Container>
);
}

View File

@@ -0,0 +1,191 @@
import apiFetch from "@/lib/apiFetch";
import {
Avatar,
Box,
Button,
Card,
Container,
Divider,
Flex,
Grid,
Group,
LoadingOverlay,
Stack,
Table,
Text,
Title,
} from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks";
import { IconPhone } from "@tabler/icons-react";
import _ from "lodash";
import { useLocation, useNavigate } from "react-router-dom";
import useSwr from "swr";
export default function DetailWargaPage() {
const { search } = useLocation();
const query = new URLSearchParams(search);
const id = query.get("id");
const { data, mutate, isLoading } = useSwr("/", () =>
apiFetch.api.warga.detail.get({
query: {
id: id!,
},
}),
);
useShallowEffect(() => {
mutate();
}, []);
return (
<>
<LoadingOverlay visible={isLoading} zIndex={1000} overlayProps={{ radius: "sm", blur: 2 }} />
<Container size="xl" py="xl" w={"100%"}>
<Grid>
<Grid.Col span={4}>
<DetailWarga data={data?.data?.warga} />
</Grid.Col>
<Grid.Col span={8}>
<Stack gap={"xl"}>
<DetailDataHistori data={data?.data?.pengaduan} kategori="pengaduan" />
<DetailDataHistori data={data?.data?.pelayanan} kategori="pelayanan" />
</Stack>
</Grid.Col>
</Grid>
</Container>
</>
);
}
function DetailDataHistori({ data, kategori }: { data: any, kategori: 'pengaduan' | 'pelayanan' }) {
const navigate = useNavigate();
return (
<Card
radius="md"
p="lg"
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 20px rgba(0,255,200,0.08)",
}}
>
<Stack gap="md">
<Flex align="center" justify="space-between">
<Title order={4} c="gray.2">
Histori {_.upperFirst(kategori)}
</Title>
</Flex>
<Divider my={0} />
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>No {_.upperFirst(kategori)}</Table.Th>
<Table.Th>{kategori == "pengaduan" ? "Judul" : "Kategori"}</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{
data?.length > 0 ? (
data?.map((item: any, index: number) => (
<Table.Tr key={index}>
<Table.Td>{item.noPengaduan}</Table.Td>
<Table.Td>{kategori == "pengaduan" ? item.title : item.category}</Table.Td>
<Table.Td>{item.status}</Table.Td>
<Table.Td>
<Button
variant="outline"
onClick={() => {
kategori == "pengaduan" ?
navigate(
`/scr/dashboard/pengaduan/detail?id=${item.id}`,
) :
navigate(
`/scr/dashboard/pelayanan-surat/detail-pelayanan?id=${item.id}`,
)
}}
>
Detail
</Button>
</Table.Td>
</Table.Tr>
))
) : (
<Table.Tr>
<Table.Td colSpan={4} align="center">Tidak ada data</Table.Td>
</Table.Tr>
)
}
</Table.Tbody>
</Table>
</Stack>
</Card>
);
}
function DetailWarga({ data }: { data: any }) {
return (
<Card
radius="md"
p="lg"
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 20px #00ffc814",
}}
>
<Box
style={{
background:
"linear-gradient(to left top, #23633a, #00685b, #006984, #0065a5, #0059b1, #114ca3, #193f94, #1d3285, #202864, #1d1f45, #171628, #0b0b0b)",
height: 100,
borderRadius: "12px",
position: "relative",
}}
/>
<Group>
<Avatar
radius={100}
size={90}
style={{
position: "absolute",
top: 80,
left: 30,
border: "3x solid white",
backgroundColor: "#099268",
}}
>
A
</Avatar>
{/* Main content */}
<Stack ml={115} gap={4}>
<Text fw={700} fz="lg">
{data?.name}
</Text>
<Text fz="sm" c="dimmed">
Warga Desa
</Text>
</Stack>
</Group>
{/* Contact info */}
<Card radius="md" mt="md" p="md" withBorder={false}>
<Stack gap="xs">
<Group gap="xs">
<IconPhone size={18} />
<Text size="sm">{data?.phone}</Text>
</Group>
</Stack>
</Card>
</Card>
);
}

View File

@@ -0,0 +1,112 @@
import apiFetch from "@/lib/apiFetch";
import {
Button,
Card,
CloseButton,
Container,
Divider,
Flex,
Input,
Stack,
Table,
Title,
} from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks";
import { IconSearch } from "@tabler/icons-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import useSWR from "swr";
export default function ListWargaPage() {
const navigate = useNavigate();
const { data, mutate, isLoading } = useSWR("/", () =>
apiFetch.api.warga.list.get({
query: {
search: value,
},
}),
);
const list = data?.data || [];
const [value, setValue] = useState("");
useShallowEffect(() => {
mutate();
}, [value]);
return (
<Container size="xl" py="xl" w={"100%"}>
<Card
radius="lg"
p="xl"
withBorder
style={{
background:
"linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))",
}}
>
<Stack gap="md">
<Flex align="center" justify="space-between">
<Title order={3} c="gray.2">
List Data Warga
</Title>
<Input
value={value}
placeholder="Cari warga..."
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" }}
/>
}
/>
</Flex>
<Divider my={0} />
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Nama</Table.Th>
<Table.Th>No Telepon</Table.Th>
<Table.Th>Aksi</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{
list?.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={3} align="center">Tidak ada data</Table.Td>
</Table.Tr>
) : (
list?.map((item, i) => (
<Table.Tr key={i}>
<Table.Td>{item.name}</Table.Td>
<Table.Td>{item.phone}</Table.Td>
<Table.Td>
<Button
variant="outline"
onClick={() => {
navigate(
`/scr/dashboard/warga/detail-warga?id=${item.id}`,
);
}}
>
Detail
</Button>
</Table.Td>
</Table.Tr>
))
)
}
</Table.Tbody>
</Table>
</Stack>
</Card>
</Container>
);
}

View File

@@ -1,5 +1,4 @@
import _ from "lodash";
import { v4 as uuidv4 } from "uuid";
interface McpTool {
name: string;
@@ -16,57 +15,50 @@ interface McpTool {
}
/**
* Convert OpenAPI 3.x JSON spec into MCP-compatible tool definitions (without run()).
* Hanya menyertakan endpoint yang memiliki tag berisi "mcp".
* Convert OpenAPI 3.x JSON spec into MCP-compatible tool definitions.
*/
export function convertOpenApiToMcpTools(openApiJson: any): McpTool[] {
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)) {
// ✅ skip semua path internal MCP
if (!path || typeof path !== "string") continue;
if (path.startsWith("/mcp")) continue;
for (const [method, operation] of Object.entries<any>(methods as any)) {
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 : [];
// ✅ exclude semua yang tidak punya tag atau tag-nya tidak mengandung "mcp"
if (!tags.length || !tags.some(t => t.toLowerCase().includes("mcp"))) continue;
if (!tags.length || !tags.some(t =>
typeof t === "string" && t.toLowerCase().includes(filterTag)
)) continue;
const rawName = _.snakeCase(operation.operationId || `${method}_${path}`) || "unnamed_tool";
const name = cleanToolName(rawName);
const description =
operation.description ||
operation.summary ||
`Execute ${method.toUpperCase()} ${path}`;
const schema =
operation.requestBody?.content?.["application/json"]?.schema || {
type: "object",
properties: {},
additionalProperties: true,
};
const tool: McpTool = {
name,
description,
"x-props": {
method: method.toUpperCase(),
path,
operationId: operation.operationId,
tag: tags[0],
deprecated: operation.deprecated || false,
summary: operation.summary,
},
inputSchema: {
...schema,
additionalProperties: true,
$schema: "http://json-schema.org/draft-07/schema#",
},
};
tools.push(tool);
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;
}
}
}
@@ -74,35 +66,316 @@ export function convertOpenApiToMcpTools(openApiJson: any): McpTool[] {
}
/**
* Bersihkan nama agar valid untuk digunakan sebagai tool name
* - hapus karakter spesial
* - ubah slash jadi underscore
* - hilangkan prefix umum (get_, post_, api_, dll)
* - rapikan underscore berganda
* 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 {
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, "");
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() {
const data = await fetch(`${process.env.BUN_PUBLIC_BASE_URL}/docs/json`);
const openApiJson = await data.json();
const tools = convertOpenApiToMcpTools(openApiJson);
return tools;
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;
}
}
// === CLI Mode ===
if (import.meta.main) {
const tools = await getMcpTools();
await Bun.write("./tools.json", JSON.stringify(tools, null, 2));
}

View File

@@ -0,0 +1,108 @@
import _ from "lodash";
import { v4 as uuidv4 } from "uuid";
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 (without run()).
* Hanya menyertakan endpoint yang memiliki tag berisi "mcp".
*/
export function convertOpenApiToMcpTools(openApiJson: any): McpTool[] {
const tools: McpTool[] = [];
const paths = openApiJson.paths || {};
for (const [path, methods] of Object.entries(paths)) {
// ✅ skip semua path internal MCP
if (path.startsWith("/mcp")) continue;
for (const [method, operation] of Object.entries<any>(methods as any)) {
const tags: string[] = Array.isArray(operation.tags) ? operation.tags : [];
// ✅ exclude semua yang tidak punya tag atau tag-nya tidak mengandung "mcp"
if (!tags.length || !tags.some(t => t.toLowerCase().includes("mcp"))) continue;
const rawName = _.snakeCase(operation.operationId || `${method}_${path}`) || "unnamed_tool";
const name = cleanToolName(rawName);
const description =
operation.description ||
operation.summary ||
`Execute ${method.toUpperCase()} ${path}`;
const schema =
operation.requestBody?.content?.["application/json"]?.schema || {
type: "object",
properties: {},
additionalProperties: true,
};
const tool: McpTool = {
name,
description,
"x-props": {
method: method.toUpperCase(),
path,
operationId: operation.operationId,
tag: tags[0],
deprecated: operation.deprecated || false,
summary: operation.summary,
},
inputSchema: {
...schema,
additionalProperties: true,
$schema: "http://json-schema.org/draft-07/schema#",
},
};
tools.push(tool);
}
}
return tools;
}
/**
* Bersihkan nama agar valid untuk digunakan sebagai tool name
* - hapus karakter spesial
* - ubah slash jadi underscore
* - hilangkan prefix umum (get_, post_, api_, dll)
* - rapikan underscore berganda
*/
function cleanToolName(name: string): string {
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, "");
}
/**
* Ambil OpenAPI JSON dari endpoint dan konversi ke tools MCP
*/
export async function getMcpTools() {
const data = await fetch(`${process.env.BUN_PUBLIC_BASE_URL}/docs/json`);
const openApiJson = await data.json();
const tools = convertOpenApiToMcpTools(openApiJson);
return tools;
}
// === CLI Mode ===
if (import.meta.main) {
const tools = await getMcpTools();
await Bun.write("./tools.json", JSON.stringify(tools, null, 2));
}

View File

@@ -162,6 +162,7 @@ export async function uploadFile(config: Config, file: File): Promise<string> {
if (!res.ok) throw new Error(`Upload failed: ${text}`);
return `✅ Uploaded ${file.name} successfully`;
}
export async function uploadFileBase64(config: Config, base64File: { name: string; data: string; }): Promise<string> {
const remoteName = path.basename(base64File.name);
@@ -194,6 +195,38 @@ export async function uploadFileBase64(config: Config, base64File: { name: strin
return `✅ Uploaded ${base64File.name} successfully`;
}
export async function uploadFileToFolder(config: Config, base64File: { name: string; data: string; }, folder: 'syarat-dokumen' | 'pengaduan'): Promise<string> {
const remoteName = path.basename(base64File.name);
// 1. Dapatkan upload link (pakai Authorization)
const uploadUrlResponse = await fetchWithAuth(
config,
`${config.URL}/${config.REPO}/upload-link/`
);
const uploadUrl = (await uploadUrlResponse.text()).replace(/"/g, "");
// 2. Konversi base64 ke Blob
const binary = Buffer.from(base64File.data, "base64");
const blob = new Blob([binary]);
// 3. Siapkan form-data
const formData = new FormData();
formData.append("parent_dir", "/");
formData.append("relative_path", folder); // tanpa slash di akhir
formData.append("file", blob, remoteName);
// 4. Upload file TANPA Authorization header, token di query param
const res = await fetch(`${uploadUrl}?token=${config.TOKEN}`, {
method: "POST",
body: formData,
});
const text = await res.text();
if (!res.ok) throw new Error(`Upload failed: ${text}`);
return `✅ Uploaded ${base64File.name} successfully`;
}
@@ -225,49 +258,4 @@ export async function downloadFile(config: Config, remoteFile: string, localFile
export async function getFileLink(config: Config, fileName: string): Promise<string> {
const downloadUrlResponse = await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?p=/${fileName}`);
return `🔗 Link for ${fileName}:\n${(await downloadUrlResponse.text()).replace(/"/g, '')}`
}
// function showHelp(): void {
// return `note - simple CLI for wibu not
// Usage:
// not3 ls List files
// not3 cat <file> Show file content
// not3 cp <local> [remote] Upload file
// not3 rm <remote> Remove file
// not3 mv <old> <new> Rename/move file
// not3 get <remote> [local] Download file
// not3 link <file> Get file link/URL
// not3 test Test API connection
// not3 config Edit config (~/.note.conf)
// Config (~/.note.conf):
// TOKEN=your_seafile_token
// REPO=repos/<repo-id>
// URL=your_seafile_url/api2
// Version: ${version}`);
// }
// --- Main ---
// async function not3(): Promise<void> {
// const [cmd, ...args] = process.argv.slice(2);
// if (cmd === 'config') return editConfig();
// const config = await loadConfig();
// switch (cmd) {
// case 'test': return testConnection(config);
// case 'ls': return listFiles(config);
// case 'cat': return args[0] ? catFile(config, args[0]) : console.error('Usage: bun note.ts cat <file>');
// case 'cp': return args[0] ? uploadFile(config, args[0], args[1]) : console.error('Usage: bun note.ts cp <local_file> [remote_file]');
// case 'rm': return args[0] ? removeFile(config, args[0]) : console.error('Usage: bun note.ts rm <remote_file>');
// case 'mv': return args[1] ? moveFile(config, args[0]!, args[1]) : console.error('Usage: bun note.ts mv <old_name> <new_name>');
// case 'get': return args[0] ? downloadFile(config, args[0], args[1]) : console.error('Usage: bun note.ts get <remote_file> [local_file]');
// case 'link': return args[0] ? getFileLink(config, args[0]) : console.error('Usage: bun note.ts link <file>');
// default: return showHelp();
// }
// }
// not3().catch((error) => {
// console.error('❌ Error:', error);
// process.exit(1);
// });
}

View File

@@ -0,0 +1,48 @@
import Elysia, { t } from "elysia";
import { prisma } from "../lib/prisma";
const ConfigurationDesaRoute = new Elysia({
prefix: "configuration-desa",
tags: ["configuration-desa"],
})
.get("/list", async () => {
const data = await prisma.configuration.findMany({
orderBy: {
name: "asc"
}
})
return data
}, {
detail: {
summary: "List Konfigurasi",
description: `tool untuk mendapatkan list konfigurasi`,
}
})
.post("/edit", async ({ body }) => {
const { id, value } = body
await prisma.configuration.update({
where: {
id,
},
data: {
value,
}
})
return { success: true, message: 'konfigurasi sudah diperbarui' }
}, {
body: t.Object({
id: t.String({ minLength: 1, error: "id harus diisi" }),
value: t.String({ minLength: 1, error: "value harus diisi" }),
}),
detail: {
summary: "edit konfigurasi desa",
description: `tool untuk edit konfigurasi desa`
}
})
;
export default ConfigurationDesaRoute

View File

@@ -1,8 +1,13 @@
import { Elysia } from "elysia";
import { getMcpTools } from "../lib/mcp_tool_convert";
// import tools from "./../../../tools.json";
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
@@ -113,16 +118,17 @@ async function handleMCPRequestAsync(
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: [
{
type: "text",
text: JSON.stringify(result, null, 2),
},
isObject
? { type: "json", data: data }
: { type: "text", text: JSON.stringify(data || result.data || result) },
],
},
};
@@ -155,7 +161,7 @@ export const MCPRoute = new Elysia({
})
.post("/mcp", async ({ request, set }) => {
if (!tools.length) {
tools = await getMcpTools();
tools = await getMcpTools(OPENAPI_URL, FILTER_TAG);
}
set.headers["Content-Type"] = "application/json";
set.headers["Access-Control-Allow-Origin"] = "*";
@@ -189,7 +195,8 @@ export const MCPRoute = new Elysia({
// Tools list (debug)
.get("/mcp/tools", async ({ set }) => {
if (!tools.length) {
tools = await getMcpTools();
tools = await getMcpTools(OPENAPI_URL, FILTER_TAG);
}
set.headers["Access-Control-Allow-Origin"] = "*";
return {
@@ -215,7 +222,7 @@ export const MCPRoute = new Elysia({
})
.get("/mcp/init", async ({ set }) => {
const _tools = await getMcpTools();
const _tools = await getMcpTools(OPENAPI_URL, FILTER_TAG);
tools = _tools;
return {
success: true,

View File

@@ -1,8 +1,8 @@
import Elysia, { StatusMap, t } from "elysia"
import { generateNoPengajuanSurat } from "../lib/no-pengajuan-surat"
import { prisma } from "../lib/prisma"
import Elysia, { t } from "elysia"
import type { StatusPengaduan } from "generated/prisma"
import { generateNoPengajuanSurat } from "../lib/no-pengajuan-surat"
import { normalizePhoneNumber } from "../lib/normalizePhone"
import { prisma } from "../lib/prisma"
const PelayananRoute = new Elysia({
prefix: "pelayanan",
@@ -15,7 +15,7 @@ const PelayananRoute = new Elysia({
where: {
isActive: true
},
orderBy:{
orderBy: {
name: "asc"
}
})
@@ -42,8 +42,8 @@ const PelayananRoute = new Elysia({
}, {
body: t.Object({
name: t.String({ minLength: 1, error: "name harus diisi" }),
syaratDokumen: t.Array(t.String({ minLength: 1, error: "syaratDokumen harus diisi" })),
dataText: t.Array(t.String({ minLength: 1, error: "dataText harus diisi" })),
syaratDokumen: t.Any(),
dataText: t.Any(),
}),
detail: {
summary: "buat kategori pelayanan surat",
@@ -69,8 +69,8 @@ const PelayananRoute = new Elysia({
body: t.Object({
id: t.String({ minLength: 1, error: "id harus diisi" }),
name: t.String({ minLength: 1, error: "name harus diisi" }),
syaratDokumen: t.Array(t.String({ minLength: 1, error: "syaratDokumen harus diisi" })),
dataText: t.Array(t.String({ minLength: 1, error: "dataText harus diisi" })),
syaratDokumen: t.Any(),
dataText: t.Any(),
}),
detail: {
summary: "update kategori pelayanan surat",

View File

@@ -6,7 +6,7 @@ import { mimeToExtension } from "../lib/mimetypeToExtension"
import { generateNoPengaduan } from "../lib/no-pengaduan"
import { normalizePhoneNumber } from "../lib/normalizePhone"
import { prisma } from "../lib/prisma"
import { defaultConfigSF, uploadFile, uploadFileBase64 } from "../lib/seafile"
import { catFile, defaultConfigSF, testConnection, uploadFile, uploadFileBase64 } from "../lib/seafile"
const PengaduanRoute = new Elysia({
prefix: "pengaduan",
@@ -21,9 +21,15 @@ const PengaduanRoute = new Elysia({
},
orderBy: {
name: "asc"
},
select: {
id: true,
name: true,
}
})
return data
return { data }
}, {
detail: {
summary: "List Kategori Pengaduan",
@@ -100,20 +106,21 @@ const PengaduanRoute = new Elysia({
// --- PENGADUAN ---
.post("/create", async ({ body }) => {
const { title, detail, location, image, idCategory, idWarga, phone } = body
const { judulPengaduan, detailPengaduan, lokasi, namaGambar, kategoriId, wargaId, noTelepon } = body
let imageFix = namaGambar
const noPengaduan = await generateNoPengaduan()
let idCategoryFix = idCategory
let idWargaFix = idWarga
let idCategoryFix = kategoriId
let idWargaFix = wargaId
const category = await prisma.categoryPengaduan.findUnique({
where: {
id: idCategory,
id: kategoriId,
}
})
if (!category) {
const cariCategory = await prisma.categoryPengaduan.findFirst({
where: {
name: idCategory,
name: kategoriId,
}
})
@@ -127,12 +134,12 @@ const PengaduanRoute = new Elysia({
const warga = await prisma.warga.findUnique({
where: {
id: idWarga,
id: wargaId,
}
})
if (!warga) {
const nomorHP = normalizePhoneNumber({ phone })
const nomorHP = normalizePhoneNumber({ phone: noTelepon })
const cariWarga = await prisma.warga.findUnique({
where: {
phone: nomorHP,
@@ -142,7 +149,7 @@ const PengaduanRoute = new Elysia({
if (!cariWarga) {
const wargaCreate = await prisma.warga.create({
data: {
name: idWarga,
name: wargaId,
phone: nomorHP,
},
select: {
@@ -158,12 +165,12 @@ const PengaduanRoute = new Elysia({
const pengaduan = await prisma.pengaduan.create({
data: {
title,
detail,
title: judulPengaduan,
detail: detailPengaduan,
idCategory: idCategoryFix,
idWarga: idWargaFix,
location,
image,
location: lokasi,
image: imageFix,
noPengaduan,
},
select: {
@@ -172,7 +179,7 @@ const PengaduanRoute = new Elysia({
})
if (!pengaduan.id) {
throw new Error("gagal membuat pengaduan")
return { success: false, message: 'gagal membuat pengaduan' }
}
await prisma.historyPengaduan.create({
@@ -182,20 +189,77 @@ const PengaduanRoute = new Elysia({
}
})
return { success: true, message: 'pengaduan sudah dibuat' }
return { success: true, message: 'pengaduan sudah dibuat dengan nomer ' + noPengaduan + ', nomer ini akan digunakan untuk mengakses pengaduan ini' }
}, {
body: t.Object({
title: t.String({ minLength: 1, error: "title harus diisi" }),
detail: t.String({ minLength: 1, error: "detail harus diisi" }),
location: t.String({ minLength: 1, error: "location harus diisi" }),
image: t.Any(),
idCategory: t.String({ minLength: 1, error: "idCategory harus diisi" }),
idWarga: t.String({ minLength: 1, error: "idWarga harus diisi" }),
phone: t.String({ minLength: 1, error: "phone harus diisi" }),
judulPengaduan: t.String({
minLength: 3,
error: "Judul pengaduan harus diisi dan minimal 3 karakter",
examples: ["Sampah menumpuk di depan rumah"],
description: "Judul singkat dari pengaduan warga"
}),
detailPengaduan: t.String({
minLength: 5,
error: "Deskripsi pengaduan harus diisi dan minimal 10 karakter",
examples: ["Terdapat sampah yang menumpuk selama seminggu di depan rumah saya"],
description: "Penjelasan lebih detail mengenai pengaduan"
}),
lokasi: t.String({
minLength: 5,
error: "Lokasi pengaduan harus diisi",
examples: ["Jl. Raya No. 1, RT 01 RW 02, Darmasaba"],
description: "Alamat atau titik lokasi pengaduan"
}),
namaGambar: t.String({
optional: true,
examples: ["sampah.jpg"],
description: "Nama file gambar yang telah diupload (opsional)"
}),
kategoriId: t.String({
minLength: 1,
error: "ID kategori pengaduan harus diisi",
examples: ["kebersihan"],
description: "ID atau nama kategori pengaduan (contoh: kebersihan, keamanan, lainnya)"
}),
wargaId: t.String({
minLength: 1,
error: "ID warga harus diisi",
examples: ["budiman"],
description: "ID unik warga yang melapor (jika sudah terdaftar)"
}),
noTelepon: t.String({
minLength: 1,
error: "Nomor telepon harus diisi",
examples: ["08123456789", "+628123456789"],
description: "Nomor telepon warga pelapor"
}),
}),
detail: {
summary: "Create Pengaduan Warga",
description: `tool untuk membuat pengaduan warga`,
summary: "Buat Pengaduan Warga",
description: `
Endpoint ini digunakan untuk membuat data pengaduan (laporan) baru dari warga.
Alur proses:
1. Sistem memvalidasi kategori pengaduan berdasarkan ID.
- Jika ID kategori tidak ditemukan, sistem akan mencari berdasarkan nama kategori.
- Jika tetap tidak ditemukan, kategori akan diset menjadi "lainnya".
2. Sistem memvalidasi data warga berdasarkan ID.
- Jika warga tidak ditemukan, sistem akan mencari berdasarkan nomor telepon.
- Jika tetap tidak ditemukan, data warga baru akan dibuat secara otomatis.
3. Sistem menghasilkan nomor pengaduan unik (noPengaduan).
4. Data pengaduan akan disimpan ke database, termasuk judul, detail, lokasi, gambar (opsional), dan data warga.
5. Sistem juga membuat catatan riwayat awal pengaduan dengan deskripsi "Pengaduan dibuat".
Respon:
- success: true jika pengaduan berhasil dibuat.
- message: berisi pesan sukses dan nomor pengaduan yang dapat digunakan untuk melacak status pengaduan.`,
tags: ["mcp"]
}
})
@@ -252,9 +316,16 @@ const PengaduanRoute = new Elysia({
})
.get("/detail", async ({ query }) => {
const { id } = query
const data = await prisma.pengaduan.findUnique({
const data = await prisma.pengaduan.findFirst({
where: {
id,
OR: [
{
noPengaduan: id
}, {
id: id
}
]
},
select: {
id: true,
@@ -277,6 +348,13 @@ const PengaduanRoute = new Elysia({
Warga: {
select: {
name: true,
phone: true,
_count: {
select: {
Pengaduan: true,
PelayananAjuan: true,
}
}
}
}
}
@@ -305,34 +383,51 @@ const PengaduanRoute = new Elysia({
id: item.id,
deskripsi: item.deskripsi,
status: item.status,
createdAt: item.createdAt,
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 datafix = {
const warga = {
name: data?.Warga?.name,
phone: data?.Warga?.phone,
pengaduan: data?.Warga?._count.Pengaduan,
pelayanan: data?.Warga?._count.PelayananAjuan,
}
const dataPengaduan = {
id: data?.id,
noPengaduan: data?.noPengaduan,
title: data?.title,
detail: data?.detail,
location: data?.location,
image: data?.image,
CategoryPengaduan: data?.CategoryPengaduan.name,
idWarga: data?.idWarga,
nameWarga: data?.Warga?.name,
category: data?.CategoryPengaduan.name,
status: data?.status,
keterangan: data?.keterangan,
createdAt: data?.createdAt,
updatedAt: data?.updatedAt,
}
const datafix = {
pengaduan: dataPengaduan,
history: dataHistoryFix,
warga: warga,
}
return datafix
}, {
detail: {
summary: "Detail Pengaduan Warga",
description: `tool untuk mendapatkan detail pengaduan warga / history pengaduan / mengecek status pengaduan`,
description: `tool untuk mendapatkan detail pengaduan warga / history pengaduan / mengecek status pengaduan berdasarkan id atau nomer Pengaduan`,
tags: ["mcp"]
}
})
@@ -487,7 +582,6 @@ const PengaduanRoute = new Elysia({
consumes: ["multipart/form-data"]
},
})
.get("/list", async ({ query }) => {
const { take, page, search, status } = query
const skip = !page ? 0 : (Number(page) - 1) * (!take ? 10 : Number(take))
@@ -619,6 +713,34 @@ const PengaduanRoute = new Elysia({
description: `tool untuk mendapatkan jumlah pengaduan warga`,
}
})
.get("/image", async ({ query, set }) => {
const { fileName } = query
const connect = await testConnection(defaultConfigSF)
console.log({ connect })
const hasil = await catFile(defaultConfigSF, fileName)
console.log('hasilnya', hasil)
// Tentukan tipe MIME berdasarkan ekstensi
const ext = fileName.split(".").pop()?.toLowerCase();
const mime =
ext === "jpg" || ext === "jpeg"
? "image/jpeg"
: ext === "png"
? "image/png"
: "application/octet-stream";
set.headers["Content-Type"] = mime;
return new Response(hasil);
}, {
query: t.Object({
fileName: t.String(),
}),
detail: {
summary: "Gambar Pengaduan Warga",
description: `tool untuk mendapatkan gambar pengaduan warga`,
}
})
;
export default PengaduanRoute

View File

@@ -1,53 +0,0 @@
import Elysia, { t } from "elysia";
const TestRoute = new Elysia({
prefix: "test",
tags: ["mcp", "test"],
})
.get("/info-rapat-list", () => {
return {
success: true,
message: "data info rapat berhasil diambil",
data: [
{
judul: "Info Rapat",
tanggal: "2025-11-10",
deskripsi: "Info rapat",
gambar: "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/x8AAwMCAO+ip1sAAAAASUVORK5CYII="
}
]
}
}, {
detail: {
summary: "mendapatkan list rapat",
description: "mendapatkan list rapat dari database",
}
})
.post("/simpan-rapat", ({ body }) => {
if (!body.gambar) {
return {
success: false,
message: "gambar harus diisi",
}
}
return {
success: true,
message: "data info rapat berhasil diambil",
chunk: body.gambar.substring(22)
}
}, {
body: t.Object({
judul: t.String(),
tanggal: t.String(),
deskripsi: t.String(),
gambar: t.Required(t.String()),
}),
detail: {
summary: "simpan data rapat",
description: "simpan data rapat memerlukan base64 gambar",
}
})
export default TestRoute

View File

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

View File

@@ -47,5 +47,140 @@ const UserRoute = new Elysia({
description: "upsert user",
}
})
.post("/update-password", async ({ body }) => {
const { password, id } = body
const update = await prisma.user.update({
where: {
id
},
data: {
password
}
})
return {
success: true,
message: "Password updated successfully",
}
}, {
body: t.Object({
password: t.String({ minLength: 1, error: "password is required" }),
id: t.String({ minLength: 1, error: "id is required" })
}),
detail: {
summary: "update password",
description: "update password user",
}
})
.post("/update", async ({ body }) => {
const { name, phone, id, roleId } = body
const update = await prisma.user.update({
where: {
id
},
data: {
name,
phone,
roleId
}
})
return {
success: true,
message: "User updated successfully",
}
}, {
body: t.Object({
name: t.String({ minLength: 1, error: "name is required" }),
phone: t.String({ minLength: 1, error: "phone is required" }),
id: t.String({ minLength: 1, error: "id is required" }),
roleId: t.String({ minLength: 1, error: "roleId is required" })
}),
detail: {
summary: "update",
description: "update user",
}
})
.post("/create", async ({ body }) => {
const { name, phone, roleId, email, password } = body
const create = await prisma.user.create({
data: {
name,
phone,
roleId,
email,
password
}
})
return {
success: true,
message: "User created successfully",
}
}, {
body: t.Object({
name: t.String({ minLength: 1, error: "name is required" }),
phone: t.String({ minLength: 1, error: "phone is required" }),
roleId: t.String({ minLength: 1, error: "roleId is required" }),
email: t.String({ minLength: 1, error: "email is required" }),
password: t.String({ minLength: 1, error: "password is required" })
}),
detail: {
summary: "create",
description: "create user",
}
})
.get("/list", async (ctx) => {
const { user } = ctx as any
const data = await prisma.user.findMany({
where: {
isActive: true,
NOT: {
id: user.id
}
}
})
return data
}, {
detail: {
summary: "list",
description: "list user",
}
})
.get("/role", async () => {
const data = await prisma.role.findMany()
return data
}, {
detail: {
summary: "role",
description: "role user",
}
})
.post("/delete", async ({ body }) => {
const { id } = body
const deleteData = await prisma.user.update({
where: {
id
},
data: {
isActive: false
}
})
return {
success: true,
message: "User deleted successfully",
}
}, {
body: t.Object({
id: t.String({ minLength: 1, error: "id is required" })
}),
detail: {
summary: "delete",
description: "delete user",
}
})
export default UserRoute

View File

@@ -0,0 +1,141 @@
import Elysia, { t } from "elysia";
import _ from "lodash";
import { normalizePhoneNumber } from "../lib/normalizePhone";
import { prisma } from "../lib/prisma";
const WargaRoute = new Elysia({
prefix: "warga",
tags: ["warga"],
})
.get("/list", async ({ query }) => {
const { search } = query
const data = await prisma.warga.findMany({
where: {
OR: [
{
name: {
contains: search,
mode: "insensitive"
}
},
{
phone: {
contains: search,
mode: "insensitive"
}
}
]
},
orderBy: {
name: "asc"
}
})
return data
}, {
detail: {
summary: "List Warga",
description: `tool untuk mendapatkan list warga`,
}
})
.post("/edit", async ({ body }) => {
const { id, name, phone } = body
const nomorHP = normalizePhoneNumber({ phone })
await prisma.warga.update({
where: {
id,
},
data: {
name,
phone: nomorHP
}
})
return { success: true, message: 'data warga sudah diperbarui' }
}, {
body: t.Object({
id: t.String({ minLength: 1, error: "id harus diisi" }),
name: t.String({ minLength: 1, error: "value harus diisi" }),
phone: t.String({ minLength: 1 })
}),
detail: {
summary: "edit konfigurasi desa",
description: `tool untuk edit konfigurasi desa`
}
})
.get("/detail", async ({ query }) => {
const { id } = query
const dataWarga = await prisma.warga.findUnique({
where: {
id
}
})
const dataPengaduan = await prisma.pengaduan.findMany({
orderBy: {
createdAt: "desc"
},
where: {
isActive: true,
idWarga: id
},
select: {
id: true,
status: true,
noPengaduan: true,
title: true
}
})
const dataPelayanan = await prisma.pelayananAjuan.findMany({
orderBy: {
createdAt: "desc"
},
where: {
isActive: true,
idWarga: id
},
select: {
id: true,
noPengajuan: true,
status: true,
CategoryPelayanan: {
select: {
name: true
}
}
}
})
const dataPelayanFix = dataPelayanan.map((v: any) => ({
..._.omit(v, ["CategoryPelayanan"]),
id: v.id,
noPengaduan: v.noPengajuan,
status: v.status,
category: v.CategoryPelayanan.name
}))
return {
warga: dataWarga,
pengaduan: dataPengaduan,
pelayanan: dataPelayanFix
}
}, {
query: t.Object({
id: t.String({ minLength: 1, error: "id harus diisi" })
}),
detail: {
summary: "Detail Warga",
description: `tool untuk mendapatkan detail warga`,
}
})
;
export default WargaRoute

108
xx.ts
View File

@@ -1,65 +1,45 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import { Elysia } from 'elysia'
import jwt, { type JWTPayloadSpec } from '@elysiajs/jwt'
import bearer from '@elysiajs/bearer'
import { prisma } from '../lib/prisma'
// =========================================================
// JWT Secret Validation
// =========================================================
const secret = process.env.JWT_SECRET
if (!secret) throw new Error('JWT_SECRET environment variable is missing')
// =========================================================
// Auth Middleware Plugin
// =========================================================
export default function apiAuth(app: Elysia) {
if (!secret) throw new Error('JWT_SECRET environment variable is missing')
return app
// Register Bearer and JWT plugins
.use(bearer()) // ✅ Extracts Bearer token automatically (case-insensitive)
.use(
jwt({
name: 'jwt',
secret,
})
)
// Derive user from JWT or cookie
.derive(async ({ bearer, cookie, jwt }) => {
// Normalize token type to string or undefined
const token =
(typeof bearer === 'string' ? bearer : undefined) ??
(typeof cookie?.token?.value === 'string' ? cookie.token.value : undefined)
let user: Awaited<ReturnType<typeof prisma.user.findUnique>> | null = null
if (token) {
try {
const decoded = (await jwt.verify(token)) as JWTPayloadSpec
if (decoded?.sub && typeof decoded.sub === 'string') {
user = await prisma.user.findUnique({
where: { id: decoded.sub },
})
}
} catch (err) {
console.warn('[SERVER][apiAuth] Invalid token:', (err as Error).message)
}
}
return { user }
})
// Protect all routes by default
.onBeforeHandle(({ user, set, request }) => {
// Whitelist public routes if needed
const publicPaths = ['/auth/login', '/auth/register', '/public']
if (publicPaths.some((path) => request.url.includes(path))) return
if (!user) {
set.status = 401
return { error: 'Unauthorized' }
}
})
{
"response": [
{
"type": "json",
"data": {
"success": true,
"status": 200,
"method": "GET",
"path": "/api/pengaduan/category",
"data": {
"data": [
{
"id": "infrastruktur",
"name": "Infrastruktur"
},
{
"id": "cmhslcvcy0000mg0810l7zx8x",
"name": "keamanan"
},
{
"id": "keamanan",
"name": "Keamanan"
},
{
"id": "kebersihan",
"name": "Kebersihan"
},
{
"id": "lainnya",
"name": "Lainnya"
},
{
"id": "pelayanan",
"name": "Pelayanan"
},
{
"id": "cmhsl5ijj0000mg08pru6kom4",
"name": "sampah"
}
]
}
}
}
]
}