Compare commits

..

36 Commits

Author SHA1 Message Date
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
2aaa44cf14 Merge pull request 'upd : dashboard admin' (#18) from amalia/10-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/18
2025-11-11 10:15:42 +08:00
fbf00a55da upd : dashboard admin
Deskripsi:
- tampilan detail pengaduan

No Issues
2025-11-10 17:47:32 +08:00
bipproduction
03955743ca tambah route texs 2025-11-10 17:12:50 +08:00
bipproduction
cdd7c6fa2b tambah route texs 2025-11-10 17:08:10 +08:00
bipproduction
c51dcfdad4 tambah route texs 2025-11-10 16:59:26 +08:00
bipproduction
e68fe87e9e tambah route texs 2025-11-10 16:55:38 +08:00
bipproduction
fca77c6bd8 tambah route texs 2025-11-10 16:53:00 +08:00
aa89a10aa8 Merge pull request 'return upload base64' (#17) from amalia/10-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/17
2025-11-10 14:11:17 +08:00
21af3e3310 return upload base64 2025-11-10 14:10:22 +08:00
08faa9f6b0 Merge pull request 'upd: json return' (#16) from amalia/10-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/16
2025-11-10 11:36:27 +08:00
b101c63f8d upd: json return 2025-11-10 11:35:51 +08:00
41820ff2b3 Merge pull request 'upload base64 fix' (#15) from amalia/10-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/15
2025-11-10 11:25:28 +08:00
9c045f32ea upload base64 fix 2025-11-10 11:24:50 +08:00
6dd8dcd06e Merge pull request 'upd: upload base64' (#14) from amalia/10-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/14
2025-11-10 10:54:52 +08:00
f79629e97e upd: upload base64 2025-11-10 10:54:22 +08:00
b52bb57fbc Merge pull request 'upload base64' (#13) from amalia/10-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/13
2025-11-10 10:35:00 +08:00
401f8f13a2 upload base64 2025-11-10 10:34:30 +08:00
7b0d4e5d30 Merge pull request 'amalia/07-nov-25' (#12) from amalia/07-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/12
2025-11-07 17:35:13 +08:00
5c71d000f6 tampilan pelayanan surat
Deskripsi:
- tampilan list pelayanan surat

No Issues
2025-11-07 17:34:07 +08:00
621cfc931a upd: upload base64
Deskripsi:
- api upload base64 test

No Issues
2025-11-07 16:22:56 +08:00
928ecb4c76 upd: list pengaduan
Deskripsi:
- pencarian data list pengaduan

No Issues
2025-11-07 15:19:23 +08:00
0ac649345d Merge pull request 'amalia/07-nov-25' (#11) from amalia/07-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/11
2025-11-07 12:07:58 +08:00
e0456b2dba upd: list pengaduan dashboard 2025-11-07 12:06:46 +08:00
14ec81d98d upd: tambah cors 2025-11-07 12:06:04 +08:00
0e5fab6a84 Merge pull request 'amalia/06-nov-25' (#10) from amalia/06-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/10
2025-11-06 17:39:00 +08:00
89e83d806e upd: dashboard
Deskripsi:
- menu dashboard
- tampilan pengaduan list

No Issues
2025-11-06 17:37:21 +08:00
df7f93c794 upd: configurasi desa
Deskripsi:
- update table database
- seeder configurasi desa

NO Issues
2025-11-06 12:23:25 +08:00
de594acbf6 upd: api pelayanan surat 2025-11-06 11:29:40 +08:00
84d2388eb8 upd: coba upload file
Deskripsi:
- seafile upload
- coba api upload file api

No Issues
2025-11-06 10:44:40 +08:00
25f92e3686 Merge pull request 'upd: upload gambar' (#9) from amalia/05-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/9
2025-11-05 17:24:31 +08:00
169b2b0e3e Merge pull request 'upd: api' (#8) from amalia/04-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/8
2025-11-04 15:33:42 +08:00
23 changed files with 2367 additions and 298 deletions

View File

@@ -193,7 +193,7 @@ model SuratPelayanan {
model Configuration {
id String @id @default(cuid())
category String
name String
value String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

View File

@@ -1,4 +1,5 @@
import { categoryPelayananSurat } from "@/lib/categoryPelayananSurat";
import { confDesa } from "@/lib/configurationDesa";
import { prisma } from "@/server/lib/prisma";
const category = [
@@ -51,7 +52,6 @@ const user = [
(async () => {
for (const r of role) {
console.log(`Seeding role ${r.name}`)
await prisma.role.upsert({
where: { id: r.id },
create: r,
@@ -81,7 +81,7 @@ const user = [
console.log(`✅ Category ${c.name} seeded successfully`)
}
for (const cp of categoryPelayananSurat){
for (const cp of categoryPelayananSurat) {
await prisma.categoryPelayanan.upsert({
where: { id: cp.id },
create: cp,
@@ -91,6 +91,15 @@ const user = [
console.log(`✅ Category Pelayanan ${cp.name} seeded successfully`)
}
for (const c of confDesa) {
await prisma.configuration.upsert({
where: { id: c.id },
create: c,
update: c
})
console.log(`✅ Configuration ${c.name} seeded successfully`)
}

View File

@@ -17,8 +17,15 @@ import FormSuratKeteranganKelakuanBaik from "./pages/darmasaba/form_surat_ketera
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";
@@ -92,10 +99,38 @@ export default function AppRoutes() {
path="/scr/dashboard/dashboard-home"
element={<DashboardHome />}
/>
<Route
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 />}
/>
<Route
path="/scr/dashboard/pengaduan/detail"
element={<DetailPage />}
/>
<Route
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

@@ -19,7 +19,14 @@ const clientRoutes = {
"/scr/dashboard": "/scr/dashboard",
"/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

@@ -12,7 +12,9 @@ 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 UserRoute from "./server/routes/user_route";
import cors from "@elysiajs/cors";
const Docs = new Elysia({
tags: ["docs"],
@@ -28,6 +30,7 @@ const Api = new Elysia({
})
.use(PengaduanRoute)
.use(PelayananRoute)
.use(TestRoute)
.use(apiAuth)
.use(ApiKeyRoute)
.use(DarmasabaRoute)
@@ -40,6 +43,13 @@ const app = new Elysia()
.use(Api)
.use(Docs)
.use(Auth)
.use(
cors({
origin: "*",
methods: ["GET", "POST", "OPTIONS"],
allowedHeaders: ["Content-Type"],
}),
)
.get(
"/.well-known/mcp.json",
async () => {

View File

@@ -0,0 +1,57 @@
export const confDesa = [
{
id: "desaNama",
name: "Nama Desa",
value: "Darmasaba"
},
{
id: "desaKabupaten",
name: "Kabupaten",
value: "Badung"
},
{
id: "desaKecamatan",
name: "Kecamatan",
value: "Abiansemal"
},
{
id: "desaAlamat",
name: "Alamat Kantor Desa",
value: "Jl. Raya Darmasaba No.22, Darmasaba"
},
{
id: "desaPos",
name: "Kode Pos",
value: "80352"
},
{
id: "desaTelepon",
name: "Telepon",
value: "081239580000"
},
{
id: "desaEmail",
name: "Email",
value: "desadarmasaba@badungkab.go.id"
},
{
id: "perbekelNama",
name: "Nama Perbekel",
value: "Ida Bagus Surya Prabhawa Manuaba, S.H., M.H., N.L.P."
},
{
id: "perbekelJabatan",
name: "Jabatan",
value: "Perbekel"
},
{
id: "perbekelNIP",
name: "NIP",
value: ""
},
{
id: "perbekelTTD",
name: "TTD",
value: ""
},
];

View File

@@ -2,16 +2,16 @@ import { Tree } from "@mantine/core";
// ✅ Valid data, all values are unique
const data = [
{
value: 'src',
label: 'src',
children: [
{ value: 'src/components', label: 'components' },
{ value: 'src/hooks', label: 'hooks' },
],
},
{ value: 'package.json', label: 'package.json' },
];
{
value: "src",
label: "src",
children: [
{ value: "src/components", label: "components" },
{ value: "src/hooks", label: "hooks" },
],
},
{ value: "package.json", label: "package.json" },
];
export default function DirPage() {
return (

View File

@@ -1,4 +1,8 @@
import { useEffect, useState } from "react";
import {
default as clientRoute,
default as clientRoutes,
} from "@/clientRoutes";
import apiFetch from "@/lib/apiFetch";
import {
ActionIcon,
AppShell,
@@ -22,17 +26,17 @@ import {
IconChevronLeft,
IconChevronRight,
IconDashboard,
IconFileCertificate,
IconKey,
IconLock,
IconMessageReport,
IconSettings,
IconUser,
IconUsersGroup,
} from "@tabler/icons-react";
import type { User } from "generated/prisma";
import { useEffect, useState } from "react";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import {
default as clientRoute,
default as clientRoutes,
} from "@/clientRoutes";
import apiFetch from "@/lib/apiFetch";
function Logout() {
return (
@@ -98,7 +102,7 @@ export default function DashboardLayout() {
size="lg"
style={{
backgroundColor: "rgba(255,255,255,0.05)",
boxShadow: "0 0 6px rgba(0,255,200,0.2)",
boxShadow: "0 0 6px hsla(167, 100%, 50%, 0.20), 0.20)",
}}
>
{opened ? <IconChevronLeft /> : <IconChevronRight />}
@@ -186,7 +190,7 @@ function HostView() {
{host.name}
</Text>
<Text size="sm" c="dimmed">
{host.email}
{host.roleId}
</Text>
</Stack>
</Flex>
@@ -219,6 +223,31 @@ function NavigationDashboard() {
label: "Dashboard Overview",
description: "Quick summary and insights",
},
{
path: "/scr/dashboard/pengaduan/list",
icon: <IconMessageReport size={20} />,
label: "Pengaduan Warga",
description: "Manage pengaduan warga",
},
{
path: "/scr/dashboard/pelayanan-surat/list-pelayanan",
icon: <IconFileCertificate size={20} />,
label: "Pelayanan Surat",
description: "Manage pelayanan surat",
},
{
path: "/scr/dashboard/warga/list-warga",
icon: <IconUsersGroup size={20} />,
label: "Warga",
description: "Manage warga",
},
{
path: "/scr/dashboard/setting/detail-setting",
icon: <IconSettings size={20} />,
label: "Setting",
description:
"Manage setting (category pengaduan dan pelayanan surat, desa, etc)",
},
{
path: "/scr/dashboard/apikey/apikey",
icon: <IconKey size={20} />,

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

@@ -0,0 +1,274 @@
import apiFetch from "@/lib/apiFetch";
import {
Badge,
Card,
CloseButton,
Container,
Divider,
Flex,
Group,
Input,
Stack,
Tabs,
Text,
Title,
} from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks";
import {
IconAlignJustified,
IconClockHour3,
IconFileSad,
IconMapPin,
IconSearch,
} from "@tabler/icons-react";
import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import useSwr from "swr";
import { proxy } from "valtio";
const state = proxy({ reload: "" });
function reloadState() {
state.reload = Math.random().toString();
}
export default function PelayananSuratListPage() {
const { search } = useLocation();
const query = new URLSearchParams(search);
const status = query.get("status") as StatusKey;
return (
<Container size="xl" py="xl" w={"100%"}>
<Stack gap="xl">
<TabListPelayananSurat status={status || "semua"} />
<ListPelayananSurat status={status || "semua"} />
</Stack>
</Container>
);
}
function TabListPelayananSurat({ status }: { status: string }) {
const navigate = useNavigate();
const dataCount = useSwr("/pelayanan-surat/count", () =>
apiFetch.api.pengaduan.count.get().then((res) => res.data),
);
return (
<Tabs defaultValue={status || "semua"} color="teal">
<Tabs.List grow>
<Tabs.Tab
value="all"
onClick={() => {
navigate("?status=semua");
}}
>
Semua ({dataCount?.data?.semua || 0})
</Tabs.Tab>
<Tabs.Tab
value="antrian"
onClick={() => {
navigate("?status=antrian");
}}
>
Antrian ({dataCount?.data?.antrian || 0})
</Tabs.Tab>
<Tabs.Tab
value="diterima"
onClick={() => {
navigate("?status=diterima");
}}
>
Diterima ({dataCount?.data?.diterima || 0})
</Tabs.Tab>
<Tabs.Tab
value="dikerjakan"
onClick={() => {
navigate("?status=dikerjakan");
}}
>
Dikerjakan ({dataCount?.data?.dikerjakan || 0})
</Tabs.Tab>
<Tabs.Tab
value="selesai"
onClick={() => {
navigate("?status=selesai");
}}
>
Selesai ({dataCount?.data?.selesai || 0})
</Tabs.Tab>
<Tabs.Tab
value="ditolak"
onClick={() => {
navigate("?status=ditolak");
}}
>
Ditolak ({dataCount?.data?.ditolak || 0})
</Tabs.Tab>
</Tabs.List>
</Tabs>
);
}
type StatusKey =
| "antrian"
| "diterima"
| "dikerjakan"
| "ditolak"
| "selesai"
| "semua";
function ListPelayananSurat({ status }: { status: StatusKey }) {
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 navigate = useNavigate();
if (isLoading)
return (
<Card
radius="lg"
p="xl"
withBorder
style={{
background:
"linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))",
}}
>
<Text size="sm" c="dimmed">
Loading pengaduan...
</Text>
</Card>
);
const list = data?.data || [];
return (
<Stack gap="xl">
<Group grow>
<Input
value={value}
placeholder="Cari pengaduan..."
onChange={(event) => setValue(event.currentTarget.value)}
leftSection={<IconSearch size={16} />}
rightSectionPointerEvents="all"
rightSection={
<CloseButton
aria-label="Clear input"
onClick={() => setValue("")}
style={{ display: value ? undefined : "none" }}
/>
}
/>
</Group>
{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 pelayanan surat have been added yet.
</Text>
</Stack>
</Flex>
) : (
list.map((v: any) => (
<Card
key={v.id}
radius="lg"
p="xl"
withBorder
style={{
background:
"linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))",
borderColor: "rgba(100,100,100,0.2)",
boxShadow: "0 0 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">
<Flex direction={"column"}>
<Title order={3} c="gray.2">
{v.title}
</Title>
<Group>
<Title order={6} c="gray.5">
#{v.noPengaduan}
</Title>
<Text size="sm" c="dimmed">
{v.updatedAt}
</Text>
</Group>
</Flex>
<Badge
size="xl"
variant="light"
radius="sm"
color={
v.status === "diterima"
? "green"
: v.status === "ditolak"
? "red"
: v.status === "selesai"
? "blue"
: v.status === "dikerjakan"
? "purple"
: "yellow"
}
style={{ textTransform: "none" }}
>
{v.status}
</Badge>
</Flex>
<Divider my={0} />
<Stack gap="sm">
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconClockHour3 size={20} color="white" />
<Text size="md" c="white">
Tanggal Aduan
</Text>
</Group>
<Text size="md">{v.createdAt}</Text>
</Flex>
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconMapPin size={20} color="white" />
<Text size="md" c="white">
Lokasi
</Text>
</Group>
<Text size="md">{v.location}</Text>
</Flex>
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconAlignJustified size={20} color="white" />
<Text size="md" c="white">
Detail
</Text>
</Group>
<Text size="md">{v.detail}</Text>
</Flex>
</Stack>
</Stack>
</Card>
))
)}
</Stack>
);
}

View File

@@ -0,0 +1,397 @@
import apiFetch from "@/lib/apiFetch";
import {
Anchor,
Badge,
Button,
Card,
Container,
Divider,
Flex,
Grid,
Group,
Image,
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 DetailPengaduanPage() {
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>
);
}
function DetailDataPengaduan() {
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="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"
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 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>
);
}

View File

@@ -0,0 +1,276 @@
import apiFetch from "@/lib/apiFetch";
import {
Badge,
Card,
CloseButton,
Container,
Divider,
Flex,
Group,
Input,
Stack,
Tabs,
Text,
Title,
} from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks";
import {
IconAlignJustified,
IconClockHour3,
IconFileSad,
IconMapPin,
IconSearch,
} from "@tabler/icons-react";
import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import useSwr from "swr";
import { proxy } from "valtio";
const state = proxy({ reload: "" });
function reloadState() {
state.reload = Math.random().toString();
}
export default function PengaduanListPage() {
const { search } = useLocation();
const query = new URLSearchParams(search);
const status = query.get("status") as StatusKey;
return (
<Container size="xl" py="xl" w={"100%"}>
<Stack gap="xl">
<TabListPengaduan status={status || "semua"} />
<ListPengaduan status={status || "semua"} />
</Stack>
</Container>
);
}
function TabListPengaduan({ status }: { status: string }) {
const navigate = useNavigate();
const dataCount = useSwr("/pengaduan/count", () =>
apiFetch.api.pengaduan.count.get().then((res) => res.data),
);
return (
<Tabs defaultValue={status || "semua"} color="teal">
<Tabs.List grow>
<Tabs.Tab
value="all"
onClick={() => {
navigate("?status=semua");
}}
>
Semua ({dataCount?.data?.semua || 0})
</Tabs.Tab>
<Tabs.Tab
value="antrian"
onClick={() => {
navigate("?status=antrian");
}}
>
Antrian ({dataCount?.data?.antrian || 0})
</Tabs.Tab>
<Tabs.Tab
value="diterima"
onClick={() => {
navigate("?status=diterima");
}}
>
Diterima ({dataCount?.data?.diterima || 0})
</Tabs.Tab>
<Tabs.Tab
value="dikerjakan"
onClick={() => {
navigate("?status=dikerjakan");
}}
>
Dikerjakan ({dataCount?.data?.dikerjakan || 0})
</Tabs.Tab>
<Tabs.Tab
value="selesai"
onClick={() => {
navigate("?status=selesai");
}}
>
Selesai ({dataCount?.data?.selesai || 0})
</Tabs.Tab>
<Tabs.Tab
value="ditolak"
onClick={() => {
navigate("?status=ditolak");
}}
>
Ditolak ({dataCount?.data?.ditolak || 0})
</Tabs.Tab>
</Tabs.List>
</Tabs>
);
}
type StatusKey =
| "antrian"
| "diterima"
| "dikerjakan"
| "ditolak"
| "selesai"
| "semua";
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({
query: {
status,
search: value,
take: "",
page: "",
},
}),
);
useShallowEffect(() => {
mutate();
}, [status, value]);
if (isLoading)
return (
<Card
radius="lg"
p="xl"
withBorder
style={{
background:
"linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))",
}}
>
<Text size="sm" c="dimmed">
Loading pengaduan...
</Text>
</Card>
);
const list = data?.data || [];
return (
<Stack gap="xl">
<Group grow>
<Input
value={value}
placeholder="Cari pengaduan..."
onChange={(event) => setValue(event.currentTarget.value)}
leftSection={<IconSearch size={16} />}
rightSectionPointerEvents="all"
rightSection={
<CloseButton
aria-label="Clear input"
onClick={() => setValue("")}
style={{ display: value ? undefined : "none" }}
/>
}
/>
{/* <Group justify="flex-end">
<Text size="sm">Menampilkan {Number(data?.data?.length) * (page - 1) + 1} {Math.min(10, Number(data?.data?.length) * page)} dari {Number(data?.data?.length)}</Text>
<Pagination total={Number(data?.data?.length)} value={page} onChange={setPage} withPages={false} />
</Group> */}
</Group>
{list.length === 0 ? (
<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.
</Text>
</Stack>
</Flex>
) : (
list.map((v: any) => (
<Card
key={v.id}
radius="lg"
p="xl"
withBorder
style={{
background:
"linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))",
borderColor: "rgba(100,100,100,0.2)",
boxShadow: "0 0 20px rgba(0,255,200,0.08)",
}}
onClick={() =>
navigate(`/scr/dashboard/pengaduan/detail?id=${v.id}`)
}
>
<Stack gap="md">
<Flex align="center" justify="space-between">
<Flex direction={"column"}>
<Title order={3} c="gray.2">
{v.title}
</Title>
<Group>
<Title order={6} c="gray.5">
#{v.noPengaduan}
</Title>
<Text size="sm" c="dimmed">
{v.updatedAt}
</Text>
</Group>
</Flex>
<Badge
size="xl"
variant="light"
radius="sm"
color={
v.status === "diterima"
? "green"
: v.status === "ditolak"
? "red"
: v.status === "selesai"
? "blue"
: v.status === "dikerjakan"
? "purple"
: "yellow"
}
style={{ textTransform: "none" }}
>
{v.status}
</Badge>
</Flex>
<Divider my={0} />
<Stack gap="sm">
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconClockHour3 size={20} color="white" />
<Text size="md" c="white">
Tanggal Aduan
</Text>
</Group>
<Text size="md">{v.createdAt}</Text>
</Flex>
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconMapPin size={20} color="white" />
<Text size="md" c="white">
Lokasi
</Text>
</Group>
<Text size="md">{v.location}</Text>
</Flex>
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconAlignJustified size={20} color="white" />
<Text size="md" c="white">
Detail
</Text>
</Group>
<Text size="md">{v.detail}</Text>
</Flex>
</Stack>
</Stack>
</Card>
))
)}
</Stack>
);
}

View File

@@ -0,0 +1,150 @@
import { Button, Card, Container, Divider, Flex, Grid, Group, Input, NavLink, Stack, Table, Title } from "@mantine/core";
import { IconCircleOff, IconGauge, IconHome2 } 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={<IconHome2 size={16} stroke={1.5} />}
active={type === "profile" || !type}
/>
<NavLink
href={`?type=cat-pengaduan`}
label="Kategori Pengaduan"
leftSection={<IconGauge size={16} stroke={1.5} />}
active={type === "cat-pengaduan"}
/>
<NavLink
href={`?type=cat-pelayanan`}
label="Kategori Pelayanan Surat"
leftSection={<IconCircleOff size={16} stroke={1.5} />}
active={type === "cat-pelayanan"}
/>
<NavLink
href={`?type=desa`}
label="Desa"
leftSection={<IconCircleOff 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"
? <KategoriPengaduanPage />
: type === "cat-pelayanan"
? <KategoriPengaduanPage />
: type === "desa"
? <KategoriPengaduanPage />
: <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

@@ -0,0 +1,21 @@
export function getLastUpdated(date: string | Date): string {
const now = new Date();
const updated = new Date(date);
const diffMs = now.getTime() - updated.getTime();
const diffMinutes = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMinutes < 1) return "baru saja";
if (diffMinutes < 60) return `${diffMinutes} menit lalu`;
if (diffHours < 24) return `${diffHours} jam lalu`;
if (diffDays < 7) return `${diffDays} hari lalu`;
// kalau sudah lebih dari seminggu, tampilkan tanggal
return updated.toLocaleDateString("id-ID", {
day: "numeric",
month: "long",
year: "numeric",
});
}

View File

@@ -0,0 +1,42 @@
export function mimeToExtension(mimeType: string): string {
const map: Record<string, string> = {
"image/jpeg": "jpg",
"image/png": "png",
"image/gif": "gif",
"image/webp": "webp",
"image/svg+xml": "svg",
"image/bmp": "bmp",
"image/tiff": "tiff",
"video/mp4": "mp4",
"video/webm": "webm",
"video/ogg": "ogv",
"video/quicktime": "mov",
"audio/mpeg": "mp3",
"audio/wav": "wav",
"audio/ogg": "ogg",
"audio/webm": "weba",
"application/pdf": "pdf",
"application/zip": "zip",
"application/x-zip-compressed": "zip",
"application/json": "json",
"application/javascript": "js",
"application/x-httpd-php": "php",
"application/msword": "doc",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
"application/vnd.ms-excel": "xls",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "xlsx",
"application/vnd.ms-powerpoint": "ppt",
"application/vnd.openxmlformats-officedocument.presentationml.presentation": "pptx",
"text/plain": "txt",
"text/html": "html",
"text/css": "css",
"text/csv": "csv",
"text/xml": "xml",
};
return map[mimeType.toLowerCase()] || "bin"; // default jika tidak dikenal
}

View File

@@ -136,33 +136,65 @@ export async function catFile(config: Config, fileName: string): Promise<string>
}
export async function uploadFile(config: Config, file: File): Promise<string> {
const remoteName = path.basename(file.name);
const remoteName = path.basename(file.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, "");
// 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. Siapkan form-data
const formData = new FormData();
formData.append("parent_dir", "/");
formData.append("relative_path", "syarat-dokumen"); // tanpa slash di akhir
formData.append("file", file, remoteName); // file langsung, jangan pakai Blob
// 2. Siapkan form-data
const formData = new FormData();
formData.append("parent_dir", "/");
formData.append("relative_path", "syarat-dokumen"); // tanpa slash di akhir
formData.append("file", file, remoteName); // file langsung, jangan pakai Blob
// 3. Upload file TANPA Authorization header, token di query param
const res = await fetch(`${uploadUrl}?token=${config.TOKEN}`, {
method: "POST",
body: formData,
});
// 3. 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();
console.log(text);
const text = await res.text();
if (!res.ok) throw new Error(`Upload failed: ${text}`);
return `✅ Uploaded ${file.name} successfully`;
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);
// 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", "syarat-dokumen"); // 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`;
}
export async function removeFile(config: Config, fileName: string): Promise<string> {
@@ -193,49 +225,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

@@ -2,6 +2,7 @@ import Elysia, { StatusMap, t } from "elysia"
import { generateNoPengajuanSurat } from "../lib/no-pengajuan-surat"
import { prisma } from "../lib/prisma"
import type { StatusPengaduan } from "generated/prisma"
import { normalizePhoneNumber } from "../lib/normalizePhone"
const PelayananRoute = new Elysia({
prefix: "pelayanan",
@@ -22,7 +23,7 @@ const PelayananRoute = new Elysia({
}, {
detail: {
summary: "List Kategori Pelayanan Surat",
description: `tool untuk mendapatkan list kategori pelayanan surat`,
description: `tool untuk mendapatkan list kategori pelayanan surat beserta syaratnya untuk memenuhi syarat dokumen sesuai kategori yg dipilih saat melakukan pengajuan surat`,
tags: ["mcp"]
}
})
@@ -37,10 +38,7 @@ const PelayananRoute = new Elysia({
}
})
return `
${JSON.stringify(body)}
kategori pelayanan surat sudah dibuat`
return { success: true, message: 'kategori pelayanan surat sudah dibuat' }
}, {
body: t.Object({
name: t.String({ minLength: 1, error: "name harus diisi" }),
@@ -66,10 +64,7 @@ const PelayananRoute = new Elysia({
}
})
return `
${JSON.stringify(body)}
kategori pelayanan surat sudah diperbarui`
return { success: true, message: 'kategori pelayanan surat sudah diperbarui' }
}, {
body: t.Object({
id: t.String({ minLength: 1, error: "id harus diisi" }),
@@ -94,10 +89,7 @@ const PelayananRoute = new Elysia({
}
})
return `
${JSON.stringify(body)}
kategori pelayanan surat sudah dihapus`
return { success: true, message: 'kategori pelayanan surat sudah dihapus' }
}, {
body: t.Object({
id: t.String({ minLength: 1, error: "id harus diisi" }),
@@ -175,9 +167,10 @@ const PelayananRoute = new Elysia({
})
if (!warga) {
const nomorHP = normalizePhoneNumber({ phone })
const cariWarga = await prisma.warga.findFirst({
where: {
phone,
phone: nomorHP,
}
})
@@ -185,7 +178,7 @@ const PelayananRoute = new Elysia({
const wargaCreate = await prisma.warga.create({
data: {
name: idWarga,
phone,
phone: nomorHP,
},
select: {
id: true
@@ -210,7 +203,7 @@ const PelayananRoute = new Elysia({
})
if (!pengaduan.id) {
throw new Error("gagal membuat pengaduan")
throw new Error("gagal membuat pengajuan surat")
}
let dataInsertSyaratDokumen = []
@@ -250,10 +243,7 @@ const PelayananRoute = new Elysia({
}
})
return `
${JSON.stringify(body)}
pengaduan sudah dibuat`
return { success: true, message: 'pengajuan surat sudah dibuat' }
}, {
body: t.Object({
idCategory: t.String({ minLength: 1, error: "idCategory harus diisi" }),
@@ -270,7 +260,7 @@ const PelayananRoute = new Elysia({
}),
detail: {
summary: "Create Pengajuan Pelayanan Surat",
description: `tool untuk membuat pengajuan pelayanan surat`,
description: `tool untuk membuat pengajuan pelayanan surat dengan syarat dokumen serta data text sesuai kategori pelayanan surat yang dipilih`,
tags: ["mcp"]
}
})
@@ -298,10 +288,7 @@ const PelayananRoute = new Elysia({
}
})
return `
${JSON.stringify(body)}
pengajuan surat sudah diperbarui`
return { success: true, message: 'pengajuan surat sudah diperbarui' }
}, {
body: t.Object({
id: t.String({ minLength: 1, error: "id harus diisi" }),

View File

@@ -1,10 +1,12 @@
import { swagger } from "@elysiajs/swagger"
import Elysia, { t } from "elysia"
import type { StatusPengaduan } from "generated/prisma"
import { v4 as uuidv4 } from "uuid"
import { getLastUpdated } from "../lib/get-last-updated"
import { mimeToExtension } from "../lib/mimetypeToExtension"
import { generateNoPengaduan } from "../lib/no-pengaduan"
import { prisma } from "../lib/prisma"
import { defaultConfigSF, testConnection, uploadFile } from "../lib/seafile"
import { normalizePhoneNumber } from "../lib/normalizePhone"
import { prisma } from "../lib/prisma"
import { catFile, defaultConfigSF, testConnection, uploadFile, uploadFileBase64 } from "../lib/seafile"
const PengaduanRoute = new Elysia({
prefix: "pengaduan",
@@ -38,10 +40,7 @@ const PengaduanRoute = new Elysia({
}
})
return `
${JSON.stringify(body)}
kategori pengaduan sudah dibuat`
return { success: true, message: 'kategori pengaduan sudah dibuat' }
}, {
body: t.Object({
name: t.String({ minLength: 1, error: "name harus diisi" }),
@@ -63,10 +62,7 @@ const PengaduanRoute = new Elysia({
}
})
return `
${JSON.stringify(body)}
kategori pengaduan sudah diperbarui`
return { success: true, message: 'kategori pengaduan sudah diperbarui' }
}, {
body: t.Object({
id: t.String({ minLength: 1, error: "id harus diisi" }),
@@ -89,10 +85,7 @@ const PengaduanRoute = new Elysia({
}
})
return `
${JSON.stringify(body)}
kategori pengaduan sudah dihapus`
return { success: true, message: 'kategori pengaduan sudah dihapus' }
}, {
body: t.Object({
id: t.String({ minLength: 1, error: "id harus diisi" }),
@@ -189,10 +182,7 @@ const PengaduanRoute = new Elysia({
}
})
return `
${JSON.stringify(body)}
pengaduan sudah dibuat`
return { success: true, message: 'pengaduan sudah dibuat' }
}, {
body: t.Object({
title: t.String({ minLength: 1, error: "title harus diisi" }),
@@ -246,10 +236,7 @@ const PengaduanRoute = new Elysia({
}
})
return `
${JSON.stringify(body)}
status pengaduan sudah diupdate`
return { success: true, message: 'status pengaduan sudah diupdate' }
}, {
body: t.Object({
id: t.String({ minLength: 1, error: "id harus diisi" }),
@@ -432,45 +419,233 @@ const PengaduanRoute = new Elysia({
tags: ["mcp"]
}
})
.use(swagger())
.post("/upload",
async ({ body }) => {
const { file } = body;
.post("/upload", async ({ body }) => {
const { file } = body;
// Validasi file
if (!file) {
return { success: false, message: "File tidak ditemukan" };
}
// Contoh: cek koneksi ke Seafile
const coba = await testConnection(defaultConfigSF);
console.log("Seafile Connection:", coba);
// Upload ke Seafile (pastikan uploadFile menerima Blob atau ArrayBuffer)
// const buffer = await file.arrayBuffer();
const result = await uploadFile(defaultConfigSF, file);
console.log("Upload result:", result);
return {
success: true,
message: "Upload berhasil",
filename: file.name,
size: file.size,
seafileResult: result
};
},
{
body: t.Object({
file: t.File({ format: "binary" })
}),
detail: {
summary: "Upload File",
description: "Tool untuk upload file ke Seafile",
tags: ["mcp"],
consumes: ["multipart/form-data"]
},
// Validasi file
if (!file) {
return { success: false, message: "File tidak ditemukan" };
}
);
// Upload ke Seafile (pastikan uploadFile menerima Blob atau ArrayBuffer)
// const buffer = await file.arrayBuffer();
const result = await uploadFile(defaultConfigSF, file);
return {
success: true,
message: "Upload berhasil",
filename: file.name,
size: file.size,
seafileResult: result
};
}, {
body: t.Object({
file: t.File({ format: "binary" })
}),
detail: {
summary: "Upload File",
description: "Tool untuk upload file ke Seafile",
tags: ["mcp"],
consumes: ["multipart/form-data"]
},
})
.post("/upload-base64", async ({ body }) => {
const { data, mimetype } = body;
const ext = mimeToExtension(mimetype)
const name = `${uuidv4()}.${ext}`
// Validasi file
if (!data) {
return { success: false, message: "File tidak ditemukan" };
}
// Konversi file ke base64
// const buffer = await file.arrayBuffer();
// const base64String = Buffer.from(buffer).toString("base64");
// (Opsional) jika perlu dikirim ke Seafile sebagai base64
const result = await uploadFileBase64(defaultConfigSF, { name: name, data: data });
return {
success: true,
message: "Upload berhasil",
data: {
name,
mimetype,
ext,
}
};
}, {
body: t.Object({
data: t.String(),
mimetype: t.String()
}),
detail: {
summary: "Upload File (Base64)",
description: "Tool untuk upload file ke Seafile dalam format Base64",
tags: ["mcp"],
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))
let where: any = {
isActive: true,
OR: [
{
title: {
contains: search ?? "",
mode: "insensitive"
},
},
{
noPengaduan: {
contains: search ?? "",
mode: "insensitive"
},
},
{
detail: {
contains: search ?? "",
mode: "insensitive"
},
},
{
Warga: {
phone: {
contains: search ?? "",
mode: "insensitive"
},
},
}
]
}
if (status && status !== "semua") {
where = {
...where,
status: status
}
}
const data = await prisma.pengaduan.findMany({
skip,
take: !take ? 10 : Number(take),
orderBy: {
createdAt: "desc"
},
where,
select: {
id: true,
noPengaduan: true,
title: true,
detail: true,
location: true,
status: true,
createdAt: true,
updatedAt: true,
CategoryPengaduan: {
select: {
name: true
}
},
Warga: {
select: {
name: true,
}
}
}
})
const dataFix = data.map((item) => {
return {
noPengaduan: item.noPengaduan,
id: item.id,
title: item.title,
detail: item.detail,
status: item.status,
location: item.location,
createdAt: item.createdAt.toLocaleDateString("id-ID", { day: "numeric", month: "long", year: "numeric" }),
updatedAt: 'terakhir diperbarui ' + getLastUpdated(item.updatedAt),
}
})
return dataFix
}, {
query: t.Object({
take: t.String({ optional: true }),
page: t.String({ optional: true }),
search: t.String({ optional: true }),
status: t.String({ optional: true }),
}),
detail: {
summary: "List Pengaduan Warga",
description: `tool untuk mendapatkan list pengaduan warga`,
}
})
.get("/count", async ({ query }) => {
const counts = await prisma.pengaduan.groupBy({
by: ['status'],
where: {
isActive: true,
},
_count: {
status: true,
},
});
const grouped = Object.fromEntries(
counts.map(c => [c.status, c._count.status])
);
const total = await prisma.pengaduan.count({
where: { isActive: true },
});
return {
antrian: grouped?.antrian || 0,
diterima: grouped?.diterima || 0,
dikerjakan: grouped?.dikerjakan || 0,
ditolak: grouped?.ditolak || 0,
selesai: grouped?.selesai || 0,
semua: total,
};
}, {
detail: {
summary: "Jumlah Pengaduan Warga",
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

53
src/server/routes/test.ts Normal file
View File

@@ -0,0 +1,53 @@
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

5
upload_base64.sh Normal file
View File

@@ -0,0 +1,5 @@
IMAGE_BASE64=$(base64 image.png | tr -d '\n')
curl -X POST http://localhost:3000/api/pengaduan/upload-base64 \
-H "Content-Type: application/json" \
-d "{\"file\": \"$IMAGE_BASE64\"}"

154
x.ts
View File

@@ -1,133 +1,39 @@
/**
* src/utils/swagger-to-mcp.ts
*
* Auto-converter: Swagger (OpenAPI) → MCP manifest (real-time)
*
* - Fetch swagger JSON dynamically from process.env.BUN_PUBLIC_BASE_URL + "/docs/json"
* - Generate MCP manifest for AI discovery (/.well-known/mcp.json)
* - Can be used as Bun CLI or integrated in Elysia route
*/
import fs from "fs";
import { writeFileSync } from "fs"
// 1⃣ File yang mau diupload
const filePath = "image.png";
const apiUrl = "http://localhost:3000/api/pengaduan/upload-base64";
interface OpenAPI {
info: { title?: string; description?: string; version?: string }
paths: Record<string, any>
}
// 2⃣ Baca file dan ubah ke base64
const fileBuffer = fs.readFileSync(filePath);
const base64Data = fileBuffer.toString("base64");
interface McpManifest {
schema_version: string
name: string
description: string
version?: string
endpoints: Record<string, string>
capabilities: Record<string, any>
contact?: { email?: string }
}
// 3⃣ Buat payload JSON
const payload = {
data: base64Data,
mimetype: "image/png"
};
/**
* Convert OpenAPI JSON to MCP manifest format
*/
async function convertOpenApiToMcp(baseUrl: string): Promise<McpManifest> {
const res = await fetch(`${baseUrl}/docs/json`)
if (!res.ok) throw new Error(`Failed to fetch Swagger JSON from ${baseUrl}/docs/json`)
// 4⃣ Kirim ke server pakai fetch
async function uploadBase64() {
try {
const res = await fetch(apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const openapi: OpenAPI = await res.json()
const manifest: McpManifest = {
schema_version: "1.0",
name: openapi.info?.title ?? "MCP Server",
description: openapi.info?.description ?? "Auto-generated MCP manifest from Swagger",
version: openapi.info?.version ?? "0.0.0",
endpoints: {
openapi: `${baseUrl}/docs/json`,
mcp: `${baseUrl}/.well-known/mcp.json`
},
capabilities: {}
if (!res.ok) {
throw new Error(`Request failed: ${res.status} ${res.statusText}`);
}
for (const [path, methods] of Object.entries(openapi.paths || {})) {
for (const [method, def] of Object.entries<any>(methods)) {
const tags = def.tags || ["default"]
const tag = tags[0]
const operationId = def.operationId || `${method}_${path.replace(/[\/{}]/g, "_")}`
manifest.capabilities[tag] ??= {}
// Extract parameters and body schema
const params: Record<string, string> = {}
const required: string[] = []
if (Array.isArray(def.parameters)) {
for (const p of def.parameters) {
const type = p.schema?.type || "string"
params[p.name] = type
if (p.required) required.push(p.name)
}
}
const bodySchema = def.requestBody?.content?.["application/json"]?.schema
if (bodySchema?.properties) {
for (const [key, prop] of Object.entries<any>(bodySchema.properties)) {
params[key] = prop.type || "string"
}
if (Array.isArray(bodySchema.required))
required.push(...bodySchema.required)
}
// Generate example cURL
const sampleCurl = [
`curl -X ${method.toUpperCase()} ${baseUrl}${path}`,
Object.keys(params).length > 0
? ` -H 'Content-Type: application/json' -d '${JSON.stringify(
Object.fromEntries(Object.keys(params).map(k => [k, params[k] === "string" ? k : "value"]))
)}'`
: ""
]
.filter(Boolean)
.join(" \\\n")
manifest.capabilities[tag][operationId] = {
method: method.toUpperCase(),
path,
summary: def.summary || def.description || "",
parameters: Object.keys(params).length > 0 ? params : undefined,
required: required.length > 0 ? required : undefined,
command: sampleCurl
}
}
}
return manifest
const result = await res.json();
console.log("✅ Upload sukses:", result);
} catch (err) {
console.error("❌ Upload gagal:", err);
}
}
/**
* CLI entry
* bun run src/utils/swagger-to-mcp.ts
*/
if (import.meta.main) {
const baseUrl = process.env.BUN_PUBLIC_BASE_URL
if (!baseUrl) {
console.error("❌ Missing BUN_PUBLIC_BASE_URL environment variable.")
process.exit(1)
}
convertOpenApiToMcp(baseUrl)
.then(manifest => {
writeFileSync(".well-known/mcp.json", JSON.stringify(manifest, null, 2))
console.log("✅ Generated .well-known/mcp.json")
})
.catch(err => console.error("❌ Failed to convert Swagger → MCP:", err))
}
/**
* Optional: Elysia integration
* Automatically serve /.well-known/mcp.json
*/
// import Elysia from "elysia"
// new Elysia()
// .get("/.well-known/mcp.json", async () => {
// const baseUrl = process.env.BUN_PUBLIC_BASE_URL!
// return await convertOpenApiToMcp(baseUrl)
// })
// .listen(3000)
uploadBase64();