Compare commits

...

12 Commits

Author SHA1 Message Date
5101a21f54 upd: api mcp pengaduan
Deskripsi:
- bahasa nya lebih bisa di mengerti ai bukan bahasa developer

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

No Issues
2025-11-12 17:20:14 +08:00
a4167cfc8b upd: coba api create pengaduan dengan gambar 2025-11-12 15:35:41 +08:00
14e2d711b3 upd: api create pengaduan dg image 2025-11-12 15:19:19 +08:00
63c88161d3 upd: coba api pengaduan dengan upload gambar 2025-11-12 14:57:22 +08:00
eacc8fc220 update: dashboard admin
Deskripsi:
- list data konfigurasi api
- edit data konfigurasi api
- integrasi api

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

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

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

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

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

No Issues
2025-11-11 11:00:54 +08:00
18 changed files with 1860 additions and 357 deletions

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,157 @@
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.log(error);
notification({
title: "Error",
message: "Failed to edit configuration",
type: "error",
})
} finally {
setBtnLoading(false);
}
}
function chooseEdit({ data }: { data: { id: string, value: string, name: string } }) {
setDataEdit(data);
open();
}
function onValidation({ kat, value }: { kat: 'value', value: string }) {
if (value.length < 1) {
setBtnDisable(true);
} else {
setBtnDisable(false);
}
if (kat === 'value') {
setDataEdit({ ...dataEdit, value: value });
}
}
useShallowEffect(() => {
if (dataEdit.value.length > 0) {
setBtnDisable(false);
}
}, [dataEdit.id]);
return (
<>
<Modal
opened={opened}
onClose={close}
title={"Edit"}
centered
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="ld">
<Input.Wrapper label={dataEdit.name}>
<Input value={dataEdit.value} onChange={(e) => onValidation({ kat: 'value', value: e.target.value })} />
</Input.Wrapper>
<Group justify="center" grow>
<Button variant="light" onClick={close}>
Batal
</Button>
<Button variant="filled" onClick={handleEdit} disabled={btnDisable} loading={btnLoading}>
Simpan
</Button>
</Group>
</Stack>
</Modal>
<Stack gap={"md"}>
<Flex align="center" justify="space-between">
<Title order={4} c="gray.2">
Pengaturan Desa
</Title>
</Flex>
<Divider my={0} />
<Stack gap={"md"}>
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Nama</Table.Th>
<Table.Th>Value</Table.Th>
<Table.Th>Aksi</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{list?.map((v: any) => (
<Table.Tr key={v.id}>
<Table.Td>{v.name}</Table.Td>
<Table.Td>{v.value}</Table.Td>
<Table.Td>
<Tooltip label="Edit Setting">
<ActionIcon
variant="light"
size="sm"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => chooseEdit({ data: v })}
>
<IconEdit size={20} />
</ActionIcon>
</Tooltip>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
</Stack>
</>
);
}

View File

@@ -0,0 +1,234 @@
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, IconPlus } from "@tabler/icons-react";
import { useState } from "react";
import useSWR from "swr";
import notification from "./notificationGlobal";
export default function KategoriPengaduan() {
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 { data, mutate, isLoading } = useSWR("/", () =>
apiFetch.api.pengaduan.category.get(),
);
const list = 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.log(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.log(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);
}
}
}
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>
<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>
<Tooltip label="Edit Setting">
<ActionIcon
variant="light"
size="sm"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => chooseEdit({ data: v })}
>
<IconEdit size={20} />
</ActionIcon>
</Tooltip>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
</Stack>
</>
);
}

View File

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

View File

@@ -1,3 +1,4 @@
import cors from "@elysiajs/cors";
import Swagger from "@elysiajs/swagger";
import Elysia from "elysia";
import html from "./index.html";
@@ -14,7 +15,7 @@ import PelayananRoute from "./server/routes/pelayanan_surat_route";
import PengaduanRoute from "./server/routes/pengaduan_route";
import TestRoute from "./server/routes/test";
import UserRoute from "./server/routes/user_route";
import cors from "@elysiajs/cors";
import ConfigurationDesaRoute from "./server/routes/configuration_desa_route";
const Docs = new Elysia({
tags: ["docs"],
@@ -30,6 +31,7 @@ const Api = new Elysia({
})
.use(PengaduanRoute)
.use(PelayananRoute)
.use(ConfigurationDesaRoute)
.use(TestRoute)
.use(apiAuth)
.use(ApiKeyRoute)

View File

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

View File

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

View File

@@ -133,6 +133,8 @@ function ListPelayananSurat({ status }: { status: StatusKey }) {
mutate();
}, [status, value]);
const navigate = useNavigate();
if (isLoading)
return (
<Card
@@ -169,17 +171,13 @@ 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 ? (
<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>
@@ -196,6 +194,11 @@ function ListPelayananSurat({ status }: { status: StatusKey }) {
borderColor: "rgba(100,100,100,0.2)",
boxShadow: "0 0 20px rgba(0,255,200,0.08)",
}}
onClick={() => {
navigate(
`/scr/dashboard/pelayanan-surat/detail-pelayanan?id=${v.id}`,
);
}}
>
<Stack gap="md">
<Flex align="center" justify="space-between">

View File

@@ -1,301 +1,397 @@
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,
IconPhotoScan,
IconUser,
} from "@tabler/icons-react";
import { 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");
return (
<Container size="xl" py="xl" w={"100%"}>
<Grid>
<Grid.Col span={8}>
<Stack gap={"xl"}>
<DetailDataPengaduan />
<DetailDataHistori />
</Stack>
</Grid.Col>
<Grid.Col span={4}>
<DetailUserPengaduan />
</Grid.Col>
</Grid>
</Container>
);
return (
<Container size="xl" py="xl" w={"100%"}>
<Grid>
<Grid.Col span={8}>
<Stack gap={"xl"}>
<DetailDataPengaduan />
<DetailDataHistori />
</Stack>
</Grid.Col>
<Grid.Col span={4}>
<DetailUserPengaduan />
</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)",
}}
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);
async function handleLihatGambar() {
const res = await apiFetch.api.pengaduan.image.get({
query: {
fileName: "57d5ce89-7d18-4244-9f4c-ca21b70adb7e",
},
});
console.error('client',res)
// const blob = await res.data?.blob();
// setImageSrc(URL.createObjectURL(blob!));
// openModalImage();
}
return (
<>
<Modal
opened={opened}
onClose={close}
title={"Konfirmasi"}
centered
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<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"
<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>
<Modal
opened={openedModalImage}
onClose={closeModalImage}
title="Gambar Pengaduan"
centered
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">
#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="#" onClick={handleLihatGambar}>
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"
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>
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 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>
)
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 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: "",
},
}),
);
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]);
useShallowEffect(() => {
mutate();
}, [status, value]);
const list = data?.data || [];
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>
);
return (
<Card
radius="md"
p="lg"
withBorder
style={{
background:
"linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))",
borderColor: "rgba(100,100,100,0.2)",
boxShadow: "0 0 20px rgba(0,255,200,0.08)",
}}
>
<Stack gap="md">
<Flex align="center" justify="space-between">
<Flex direction={"column"}>
<Title order={4} c="gray.2">
Warga
</Title>
</Flex>
</Flex>
<Divider my={0} />
<Stack gap="md">
<Group justify="space-between">
<Group gap="xs">
<IconUser size={20} />
<Text size="md">Nama</Text>
</Group>
<Text size="md" c={"white"}>
Amalia Dwi Yustiani
</Text>
</Group>
<Group justify="space-between">
<Group gap="xs">
<IconMapPin size={20} />
<Text size="md">Telepon</Text>
</Group>
<Text size="md" c="white">
08123456789
</Text>
</Group>
<Group justify="space-between">
<Group gap="xs">
<IconMessageReport size={20} />
<Text size="md">Jumlah Pengaduan</Text>
</Group>
<Text size="md" c="white">
10
</Text>
</Group>
<Group justify="space-between">
<Group gap="xs">
<IconFileCertificate size={20} />
<Text size="md">Jumlah Pelayanan Surat</Text>
</Group>
<Text size="md" c="white">
10
</Text>
</Group>
</Stack>
</Stack>
</Card>
);
}

View File

@@ -198,7 +198,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">

View File

@@ -0,0 +1,172 @@
import DesaSetting from "@/components/DesaSetting";
import KategoriPengaduan from "@/components/KategoriPengaduan";
import {
Button,
Card,
Container,
Divider,
Flex,
Grid,
Group,
Input,
NavLink,
Stack,
Table,
Title,
} from "@mantine/core";
import {
IconBuildingBank,
IconCategory2,
IconMailSpark,
IconUserCog,
} 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=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" ? (
<KategoriPengaduanPage />
) : type === "desa" ? (
<DesaSetting />
) : (
<ProfilePage />
)}
</Card>
</Grid.Col>
</Grid>
</Container>
);
}
function ProfilePage() {
return (
<Stack gap={"md"}>
<Flex align="center" justify="space-between">
<Title order={4} c="gray.2">
Profile Pengguna
</Title>
<Button variant="light">Edit</Button>
</Flex>
<Divider my={0} />
<Stack gap={"md"}>
<Group gap="xl" grow>
<Input.Wrapper label="Nama" description="" error="">
<Input value={"Amalia Dwi Yustiani"} readOnly />
</Input.Wrapper>
<Input.Wrapper label="Phone" description="" error="">
<Input value={"08123456789"} readOnly />
</Input.Wrapper>
</Group>
<Group gap="xl" grow>
<Input.Wrapper label="Email" description="" error="">
<Input value={"amaliadwiyustiani@gmail.com"} readOnly />
</Input.Wrapper>
<Input.Wrapper label="Role" description="" error="">
<Input value={"Admin"} readOnly />
</Input.Wrapper>
</Group>
</Stack>
</Stack>
);
}
function KategoriPengaduanPage() {
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 (
<Stack gap={"md"}>
<Flex align="center" justify="space-between">
<Title order={4} c="gray.2">
Kategori Pengaduan
</Title>
<Button variant="light">Tambah</Button>
</Flex>
<Divider my={0} />
<Stack gap={"md"}>
<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>
</Stack>
);
}

View File

@@ -0,0 +1,182 @@
import apiFetch from "@/lib/apiFetch";
import {
Avatar,
Box,
Card,
Container,
Divider,
Flex,
Grid,
Group,
Stack,
Table,
Text,
Title,
} from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks";
import { IconMail, IconMapPin, IconPhone } from "@tabler/icons-react";
import { useState } from "react";
import { useLocation } 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");
return (
<Container size="xl" py="xl" w={"100%"}>
<Grid>
<Grid.Col span={4}>
<DetailWarga />
</Grid.Col>
<Grid.Col span={8}>
<Stack gap={"xl"}>
<DetailDataHistori />
<DetailDataHistori />
</Stack>
</Grid.Col>
</Grid>
</Container>
);
}
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 DetailWarga() {
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)",
}}
>
<Box
style={{
backgroundColor: "#f7d86c",
height: 100,
borderRadius: "12px",
position: "relative",
}}
/>
<Group>
{/* Profile image */}
<Avatar
src="https://i.pravatar.cc/150?img=32"
radius={100}
size={90}
style={{
position: "absolute",
top: 80,
left: 30,
border: "4px solid white",
}}
/>
{/* Main content */}
<Stack ml={115} gap={4}>
<Text fw={700} fz="lg">
Lizbeth Moore
</Text>
<Text fz="sm" c="dimmed">
Social Media Strategies
</Text>
</Stack>
</Group>
{/* Contact info */}
<Card radius="md" mt="md" p="md" withBorder={false}>
<Stack gap="xs">
<Group gap="xs">
<IconMail size={18} />
<Text size="sm">lizbeth.moore@email.com</Text>
</Group>
<Group gap="xs">
<IconPhone size={18} />
<Text size="sm">+1 555-7788</Text>
</Group>
<Group gap="xs">
<IconMapPin size={18} />
<Text size="sm">Greenway Ave, Los Angeles, CA, USA</Text>
</Group>
</Stack>
</Card>
</Card>
);
}

View File

@@ -0,0 +1,97 @@
import {
Button,
Card,
CloseButton,
Container,
Divider,
Flex,
Input,
Stack,
Table,
Title,
} from "@mantine/core";
import { IconSearch } from "@tabler/icons-react";
import { useState } from "react";
import { useNavigate } from "react-router-dom";
export default function ListWargaPage() {
const navigate = useNavigate();
const [value, setValue] = useState("");
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.Td>
<Button
variant="outline"
onClick={() => {
navigate(
`/scr/dashboard/warga/detail-warga?id=${element.position}`,
);
}}
>
Detail
</Button>
</Table.Td>
</Table.Tr>
));
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>Tanggal</Table.Th>
<Table.Th>Deskripsi</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>User</Table.Th>
<Table.Th>Aksi</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{rows}</Table.Tbody>
</Table>
</Stack>
</Card>
</Container>
);
}

View File

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

View File

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

View File

@@ -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",
@@ -100,20 +100,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 +128,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 +143,7 @@ const PengaduanRoute = new Elysia({
if (!cariWarga) {
const wargaCreate = await prisma.warga.create({
data: {
name: idWarga,
name: wargaId,
phone: nomorHP,
},
select: {
@@ -158,12 +159,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 +173,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 +183,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"]
}
})
@@ -255,6 +313,11 @@ const PengaduanRoute = new Elysia({
const data = await prisma.pengaduan.findUnique({
where: {
id,
OR: [
{
noPengaduan: id
}
]
},
select: {
id: true,
@@ -332,7 +395,7 @@ const PengaduanRoute = new Elysia({
}, {
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 +550,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 +681,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