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
This commit is contained in:
2025-11-07 17:35:13 +08:00
9 changed files with 608 additions and 182 deletions

View File

@@ -1,28 +1,29 @@
// ⚡ Auto-generated by generateRoutes.ts — DO NOT EDIT MANUALLY // ⚡ Auto-generated by generateRoutes.ts — DO NOT EDIT MANUALLY
import { BrowserRouter, Route, Routes } from "react-router-dom"; import { BrowserRouter, Routes, Route } from "react-router-dom";
import DarmasabaHome from "./pages/darmasaba/darmasaba_home";
import DarmasabaLayout from "./pages/darmasaba/darmasaba_layout";
import FormKartuKeluarga from "./pages/darmasaba/form_kartu_keluarga";
import FormKartuTandaPenduduk from "./pages/darmasaba/form_kartu_tanda_penduduk";
import FormKeteranganKelahiran from "./pages/darmasaba/form_keterangan_kelahiran";
import FormLaporanSampah from "./pages/darmasaba/form_laporan_sampah";
import FormSuratKeteranganBelumKawin from "./pages/darmasaba/form_surat_keterangan_belum_kawin";
import FormSuratKeteranganDomisiliOrganisasi from "./pages/darmasaba/form_surat_keterangan_domisili_organisasi";
import FormSuratKeteranganKelakuanBaik from "./pages/darmasaba/form_surat_keterangan_kelakuan_baik";
import FormSuratKeteranganPenghasilan from "./pages/darmasaba/form_surat_keterangan_penghasilan";
import FormSuratKeteranganTempatUsaha from "./pages/darmasaba/form_surat_keterangan_tempat_usaha";
import FormSuratKeteranganTidakMampu from "./pages/darmasaba/form_surat_keterangan_tidak_mampu";
import FormSuratKeteranganUsaha from "./pages/darmasaba/form_surat_keterangan_usaha";
import DirPage from "./pages/dir/dir_page";
import Home from "./pages/Home";
import Login from "./pages/Login"; import Login from "./pages/Login";
import NotFound from "./pages/NotFound"; import DarmasabaLayout from "./pages/darmasaba/darmasaba_layout";
import ApikeyPage from "./pages/scr/dashboard/apikey/apikey_page"; import FormSuratKeteranganUsaha from "./pages/darmasaba/form_surat_keterangan_usaha";
import FormSuratKeteranganTidakMampu from "./pages/darmasaba/form_surat_keterangan_tidak_mampu";
import DarmasabaHome from "./pages/darmasaba/darmasaba_home";
import FormKartuTandaPenduduk from "./pages/darmasaba/form_kartu_tanda_penduduk";
import FormKartuKeluarga from "./pages/darmasaba/form_kartu_keluarga";
import FormLaporanSampah from "./pages/darmasaba/form_laporan_sampah";
import FormSuratKeteranganPenghasilan from "./pages/darmasaba/form_surat_keterangan_penghasilan";
import FormSuratKeteranganDomisiliOrganisasi from "./pages/darmasaba/form_surat_keterangan_domisili_organisasi";
import FormSuratKeteranganBelumKawin from "./pages/darmasaba/form_surat_keterangan_belum_kawin";
import FormKeteranganKelahiran from "./pages/darmasaba/form_keterangan_kelahiran";
import FormSuratKeteranganTempatUsaha from "./pages/darmasaba/form_surat_keterangan_tempat_usaha";
import FormSuratKeteranganKelakuanBaik from "./pages/darmasaba/form_surat_keterangan_kelakuan_baik";
import Home from "./pages/Home";
import CredentialPage from "./pages/scr/dashboard/credential/credential_page"; import CredentialPage from "./pages/scr/dashboard/credential/credential_page";
import DashboardHome from "./pages/scr/dashboard/dashboard_home"; import DashboardHome from "./pages/scr/dashboard/dashboard_home";
import DashboardLayout from "./pages/scr/dashboard/dashboard_layout"; import ListPelayananPage from "./pages/scr/dashboard/pelayanan-surat/list_pelayanan_page";
import ListPage from "./pages/scr/dashboard/pengaduan/list_page"; import ListPage from "./pages/scr/dashboard/pengaduan/list_page";
import ApikeyPage from "./pages/scr/dashboard/apikey/apikey_page";
import DashboardLayout from "./pages/scr/dashboard/dashboard_layout";
import ScrLayout from "./pages/scr/scr_layout"; import ScrLayout from "./pages/scr/scr_layout";
import DirPage from "./pages/dir/dir_page";
import NotFound from "./pages/NotFound";
export default function AppRoutes() { export default function AppRoutes() {
return ( return (
@@ -93,6 +94,10 @@ export default function AppRoutes() {
path="/scr/dashboard/dashboard-home" path="/scr/dashboard/dashboard-home"
element={<DashboardHome />} element={<DashboardHome />}
/> />
<Route
path="/scr/dashboard/pelayanan-surat/list-pelayanan"
element={<ListPelayananPage />}
/>
<Route <Route
path="/scr/dashboard/pengaduan/list" path="/scr/dashboard/pengaduan/list"
element={<ListPage />} element={<ListPage />}

View File

@@ -19,6 +19,7 @@ const clientRoutes = {
"/scr/dashboard": "/scr/dashboard", "/scr/dashboard": "/scr/dashboard",
"/scr/dashboard/credential/credential": "/scr/dashboard/credential/credential", "/scr/dashboard/credential/credential": "/scr/dashboard/credential/credential",
"/scr/dashboard/dashboard-home": "/scr/dashboard/dashboard-home", "/scr/dashboard/dashboard-home": "/scr/dashboard/dashboard-home",
"/scr/dashboard/pelayanan-surat/list-pelayanan": "/scr/dashboard/pelayanan-surat/list-pelayanan",
"/scr/dashboard/pengaduan/list": "/scr/dashboard/pengaduan/list", "/scr/dashboard/pengaduan/list": "/scr/dashboard/pengaduan/list",
"/scr/dashboard/apikey/apikey": "/scr/dashboard/apikey/apikey", "/scr/dashboard/apikey/apikey": "/scr/dashboard/apikey/apikey",
"/dir/dir": "/dir/dir", "/dir/dir": "/dir/dir",

View File

@@ -13,7 +13,7 @@ import { MCPRoute } from "./server/routes/mcp_route";
import PelayananRoute from "./server/routes/pelayanan_surat_route"; import PelayananRoute from "./server/routes/pelayanan_surat_route";
import PengaduanRoute from "./server/routes/pengaduan_route"; import PengaduanRoute from "./server/routes/pengaduan_route";
import UserRoute from "./server/routes/user_route"; import UserRoute from "./server/routes/user_route";
import cors from "@elysiajs/cors" import cors from "@elysiajs/cors";
const Docs = new Elysia({ const Docs = new Elysia({
tags: ["docs"], tags: ["docs"],
@@ -41,11 +41,13 @@ const app = new Elysia()
.use(Api) .use(Api)
.use(Docs) .use(Docs)
.use(Auth) .use(Auth)
.use(cors({ .use(
origin: "*", cors({
methods: ["GET", "POST", "OPTIONS"], origin: "*",
allowedHeaders: ["Content-Type"], methods: ["GET", "POST", "OPTIONS"],
})) allowedHeaders: ["Content-Type"],
}),
)
.get( .get(
"/.well-known/mcp.json", "/.well-known/mcp.json",
async () => { async () => {

View File

@@ -230,7 +230,7 @@ function NavigationDashboard() {
description: "Manage pengaduan warga", description: "Manage pengaduan warga",
}, },
{ {
path: "/scr/dashboard/pelayanan", path: "/scr/dashboard/pelayanan-surat/list-pelayanan",
icon: <IconFileCertificate size={20} />, icon: <IconFileCertificate size={20} />,
label: "Pelayanan Surat", label: "Pelayanan Surat",
description: "Manage pelayanan surat", description: "Manage pelayanan surat",

View File

@@ -0,0 +1,271 @@
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]);
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)",
}}
>
<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

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

View File

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

View File

@@ -4,7 +4,7 @@ import { getLastUpdated } from "../lib/get-last-updated"
import { generateNoPengaduan } from "../lib/no-pengaduan" import { generateNoPengaduan } from "../lib/no-pengaduan"
import { normalizePhoneNumber } from "../lib/normalizePhone" import { normalizePhoneNumber } from "../lib/normalizePhone"
import { prisma } from "../lib/prisma" import { prisma } from "../lib/prisma"
import { defaultConfigSF, uploadFile } from "../lib/seafile" import { defaultConfigSF, uploadFile, uploadFileBase64 } from "../lib/seafile"
const PengaduanRoute = new Elysia({ const PengaduanRoute = new Elysia({
prefix: "pengaduan", prefix: "pengaduan",
@@ -432,38 +432,71 @@ const PengaduanRoute = new Elysia({
tags: ["mcp"] tags: ["mcp"]
} }
}) })
.post("/upload", .post("/upload", async ({ body }) => {
async ({ body }) => { const { file } = body;
const { file } = body;
// Validasi file // Validasi file
if (!file) { if (!file) {
return { success: false, message: "File tidak ditemukan" }; return { success: false, message: "File tidak ditemukan" };
} }
// Upload ke Seafile (pastikan uploadFile menerima Blob atau ArrayBuffer) // Upload ke Seafile (pastikan uploadFile menerima Blob atau ArrayBuffer)
// const buffer = await file.arrayBuffer(); // const buffer = await file.arrayBuffer();
const result = await uploadFile(defaultConfigSF, file); const result = await uploadFile(defaultConfigSF, file);
return { return {
success: true, success: true,
message: "Upload berhasil", message: "Upload berhasil",
filename: file.name, filename: file.name,
size: file.size, size: file.size,
seafileResult: result 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"]
}, },
{ })
body: t.Object({ .post("/upload-base64", async ({ body }) => {
file: t.File({ format: "binary" }) const { file } = body;
}),
detail: { // Validasi file
summary: "Upload File", if (!file) {
description: "Tool untuk upload file ke Seafile", return { success: false, message: "File tidak ditemukan" };
tags: ["mcp"], }
consumes: ["multipart/form-data"]
}, // 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: file.name, data: base64String });
return {
success: true,
message: "Upload berhasil",
filename: file.name,
size: file.size,
base64Preview: base64String.slice(0, 100) + "...", // hanya preview
seafileResult: result
};
}, {
body: t.Object({
file: t.File({ format: "binary" })
}),
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 }) => { .get("/list", async ({ query }) => {
const { take, page, search, status } = query const { take, page, search, status } = query
const skip = !page ? 0 : (Number(page) - 1) * (!take ? 10 : Number(take)) const skip = !page ? 0 : (Number(page) - 1) * (!take ? 10 : Number(take))
@@ -500,7 +533,7 @@ const PengaduanRoute = new Elysia({
] ]
} }
if (status && status !== "all") { if (status && status !== "semua") {
where = { where = {
...where, ...where,
status: status status: status
@@ -511,7 +544,7 @@ const PengaduanRoute = new Elysia({
skip, skip,
take: !take ? 10 : Number(take), take: !take ? 10 : Number(take),
orderBy: { orderBy: {
createdAt: "asc" createdAt: "desc"
}, },
where, where,
select: { select: {

2
upload_base64.sh Normal file
View File

@@ -0,0 +1,2 @@
curl -X POST http://localhost:3000/api/pengaduan/upload-base64 \
-F file=@package.json