Compare commits
43 Commits
2aaa44cf14
...
amalia/17-
| Author | SHA1 | Date | |
|---|---|---|---|
| 3e09c934d4 | |||
| 282b9678b3 | |||
| ceed3e67c7 | |||
| 04b5d26507 | |||
| 327434b42e | |||
| c4e4aaffe7 | |||
| 51e323c264 | |||
| b5d3b2bd08 | |||
| 67c066990e | |||
| 6c6ee02cf0 | |||
|
|
a3c07ca255 | ||
|
|
76c425700a | ||
|
|
467dbfa296 | ||
|
|
719f92cbe8 | ||
|
|
2075d0fba1 | ||
|
|
7d98f9f61c | ||
|
|
1a5bd72237 | ||
|
|
85cb36289c | ||
| 8d535793b1 | |||
|
|
77cbb6062b | ||
|
|
e0bef23eab | ||
|
|
4ea72fb846 | ||
|
|
d380c859a4 | ||
|
|
78f2263c86 | ||
| 254d05f5ea | |||
| 039524d092 | |||
| cc293d3bad | |||
| 6e1d3ecb56 | |||
| 5101a21f54 | |||
| 503c3e330d | |||
| 001c3df47d | |||
| a4167cfc8b | |||
| 5b240b782a | |||
| 14e2d711b3 | |||
| dc3ae99c05 | |||
| 63c88161d3 | |||
| eacc8fc220 | |||
| 422ca5a2cc | |||
| adae0d3db1 | |||
| 715a929e13 | |||
| 5b0f9b06d8 | |||
| 663e36bc4b | |||
| ddefbbbbff |
@@ -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";
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
171
src/components/DesaSetting.tsx
Normal file
171
src/components/DesaSetting.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
612
src/components/KategoriPelayananSurat.tsx
Normal file
612
src/components/KategoriPelayananSurat.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
355
src/components/KategoriPengaduan.tsx
Normal file
355
src/components/KategoriPengaduan.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
246
src/components/ProfileUser.tsx
Normal file
246
src/components/ProfileUser.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
466
src/components/UserSetting.tsx
Normal file
466
src/components/UserSetting.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
38
src/components/notificationGlobal.ts
Normal file
38
src/components/notificationGlobal.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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" }}
|
||||
|
||||
107
src/pages/scr/dashboard/setting/detail_setting_page.tsx
Normal file
107
src/pages/scr/dashboard/setting/detail_setting_page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
191
src/pages/scr/dashboard/warga/detail_warga_page.tsx
Normal file
191
src/pages/scr/dashboard/warga/detail_warga_page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
112
src/pages/scr/dashboard/warga/list_warga_page.tsx
Normal file
112
src/pages/scr/dashboard/warga/list_warga_page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
108
src/server/lib/mcp_tool_convert.txt
Normal file
108
src/server/lib/mcp_tool_convert.txt
Normal 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));
|
||||
}
|
||||
@@ -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);
|
||||
// });
|
||||
}
|
||||
48
src/server/routes/configuration_desa_route.ts
Normal file
48
src/server/routes/configuration_desa_route.ts
Normal 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
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
187
src/server/routes/test_pengaduan.ts
Normal file
187
src/server/routes/test_pengaduan.ts
Normal 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
|
||||
|
||||
@@ -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
|
||||
141
src/server/routes/warga_route.ts
Normal file
141
src/server/routes/warga_route.ts
Normal 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
108
xx.ts
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user