Compare commits
36 Commits
amalia/05-
...
amalia/11-
| Author | SHA1 | Date | |
|---|---|---|---|
| adae0d3db1 | |||
| 715a929e13 | |||
| 5b0f9b06d8 | |||
| 663e36bc4b | |||
| ddefbbbbff | |||
| 2aaa44cf14 | |||
| fbf00a55da | |||
|
|
03955743ca | ||
|
|
cdd7c6fa2b | ||
|
|
c51dcfdad4 | ||
|
|
e68fe87e9e | ||
|
|
fca77c6bd8 | ||
| aa89a10aa8 | |||
| 21af3e3310 | |||
| 08faa9f6b0 | |||
| b101c63f8d | |||
| 41820ff2b3 | |||
| 9c045f32ea | |||
| 6dd8dcd06e | |||
| f79629e97e | |||
| b52bb57fbc | |||
| 401f8f13a2 | |||
| 7b0d4e5d30 | |||
| 5c71d000f6 | |||
| 621cfc931a | |||
| 928ecb4c76 | |||
| 0ac649345d | |||
| e0456b2dba | |||
| 14ec81d98d | |||
| 0e5fab6a84 | |||
| 89e83d806e | |||
| df7f93c794 | |||
| de594acbf6 | |||
| 84d2388eb8 | |||
| 25f92e3686 | |||
| 169b2b0e3e |
@@ -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
|
||||
|
||||
@@ -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`)
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
57
src/lib/configurationDesa.ts
Normal file
57
src/lib/configurationDesa.ts
Normal 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: ""
|
||||
},
|
||||
];
|
||||
@@ -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 (
|
||||
|
||||
@@ -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} />,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
274
src/pages/scr/dashboard/pelayanan-surat/list_pelayanan_page.tsx
Normal file
274
src/pages/scr/dashboard/pelayanan-surat/list_pelayanan_page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
397
src/pages/scr/dashboard/pengaduan/detail_page.tsx
Normal file
397
src/pages/scr/dashboard/pengaduan/detail_page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
276
src/pages/scr/dashboard/pengaduan/list_page.tsx
Normal file
276
src/pages/scr/dashboard/pengaduan/list_page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
150
src/pages/scr/dashboard/setting/detail_setting_page.tsx
Normal file
150
src/pages/scr/dashboard/setting/detail_setting_page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
182
src/pages/scr/dashboard/warga/detail_warga_page.tsx
Normal file
182
src/pages/scr/dashboard/warga/detail_warga_page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
src/pages/scr/dashboard/warga/list_warga_page.tsx
Normal file
97
src/pages/scr/dashboard/warga/list_warga_page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
src/server/lib/get-last-updated.ts
Normal file
21
src/server/lib/get-last-updated.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
42
src/server/lib/mimetypeToExtension.ts
Normal file
42
src/server/lib/mimetypeToExtension.ts
Normal 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
|
||||
}
|
||||
@@ -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);
|
||||
// });
|
||||
}
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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
53
src/server/routes/test.ts
Normal 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
5
upload_base64.sh
Normal 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
154
x.ts
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user