Merge pull request 'amalia/07-nov-25' (#11) from amalia/07-nov-25 into main

Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/11
This commit is contained in:
2025-11-07 12:07:58 +08:00
4 changed files with 273 additions and 109 deletions

View File

@@ -13,6 +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"
const Docs = new Elysia({ const Docs = new Elysia({
tags: ["docs"], tags: ["docs"],
@@ -40,6 +41,11 @@ const app = new Elysia()
.use(Api) .use(Api)
.use(Docs) .use(Docs)
.use(Auth) .use(Auth)
.use(cors({
origin: "*",
methods: ["GET", "POST", "OPTIONS"],
allowedHeaders: ["Content-Type"],
}))
.get( .get(
"/.well-known/mcp.json", "/.well-known/mcp.json",
async () => { async () => {

View File

@@ -12,11 +12,10 @@ import {
Title Title
} from "@mantine/core"; } from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks"; import { useShallowEffect } from "@mantine/hooks";
import { showNotification } from "@mantine/notifications"; import { IconAlignJustified, IconClockHour3, IconFileSad, IconMapPin } from "@tabler/icons-react";
import { IconAlignJustified, IconClockHour3, IconMapPin } from "@tabler/icons-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, subscribe } from "valtio"; import { proxy } from "valtio";
const state = proxy({ reload: "" }); const state = proxy({ reload: "" });
function reloadState() { function reloadState() {
@@ -26,9 +25,7 @@ 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");
console.log(status, "status");
return ( return (
<Container <Container
@@ -38,7 +35,7 @@ export default function PengaduanListPage() {
> >
<Stack gap="xl"> <Stack gap="xl">
<TabListPengaduan status={status || "all"} /> <TabListPengaduan status={status || "all"} />
<ListPengaduan /> <ListPengaduan status={status || "all"} />
</Stack> </Stack>
</Container> </Container>
); );
@@ -46,47 +43,40 @@ 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", () =>
apiFetch.api.pengaduan.count.get().then((res) => res.data)
);
return ( return (
<Tabs defaultValue={status || "all"} color="teal"> <Tabs defaultValue={status || "all"} color="teal">
<Tabs.List grow> <Tabs.List grow>
<Tabs.Tab value="all" onClick={() => { navigate("?status=all") }}>Semua</Tabs.Tab> <Tabs.Tab value="all" onClick={() => { navigate("?status=all") }}>Semua ({dataCount?.data?.semua || 0})</Tabs.Tab>
<Tabs.Tab value="antrian" onClick={() => { navigate("?status=antrian") }}>Antrian</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</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</Tabs.Tab> <Tabs.Tab value="dikerjakan" onClick={() => { navigate("?status=dikerjakan") }}>Dikerjakan ({dataCount?.data?.dikerjakan || 0})</Tabs.Tab>
<Tabs.Tab value="ditolak" onClick={() => { navigate("?status=ditolak") }}>Ditolak</Tabs.Tab> <Tabs.Tab value="selesai" onClick={() => { navigate("?status=selesai") }}>Selesai ({dataCount?.data?.selesai || 0})</Tabs.Tab>
<Tabs.Tab value="selesai" onClick={() => { navigate("?status=selesai") }}>Selesai</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() { function ListPengaduan({ status }: { status: string }) {
const { data, mutate, isLoading } = useSwr("/", () => const { data, mutate, isLoading } = useSwr("/", () =>
apiFetch.api.credential.list.get(), apiFetch.api.pengaduan.list.get({
query: {
status,
search: "",
take: "",
page: ""
},
}),
); );
useShallowEffect(() => { useShallowEffect(() => {
const unsubscribe = subscribe(state, () => mutate()); mutate();
return () => unsubscribe(); }, [status]);
}, []);
async function handleRemove(id: string) {
try {
await apiFetch.api.credential.rm.delete({ id });
showNotification({
color: "teal",
title: "Credential Deleted",
message: "The credential was successfully removed.",
});
reloadState();
} catch {
showNotification({
color: "red",
title: "Error",
message: "Failed to delete credential. Please try again.",
});
}
}
if (isLoading) if (isLoading)
return ( return (
@@ -100,87 +90,103 @@ function ListPengaduan() {
}} }}
> >
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
Loading credentials... Loading pengaduan...
</Text> </Text>
</Card> </Card>
); );
const list = data?.data?.list || []; const list = data?.data || [];
return ( return (
<Card <Stack gap="xl">
radius="lg" {
p="xl" list.length === 0 ? (
withBorder <Flex justify="center" align="center" py={"xl"}>
style={{ <Stack gap={4} align="center">
background: <IconFileSad size={32} color="gray" />
"linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))", <Text c="dimmed" size="sm">
borderColor: "rgba(100,100,100,0.2)", No pengaduan have been added yet.
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">
Dompet Hilang
</Title>
<Group>
<Title order={6} c="gray.5">
#PGD-061125-001
</Title>
<Text size="sm" c="dimmed">
updated 2 minutes ago
</Text> </Text>
</Group> </Stack>
</Flex> </Flex>
<Badge ) :
size="xl" list.map((v: any) => (
variant="light" <Card
radius="sm" key={v.id}
color="gray" radius="lg"
style={{ textTransform: "none" }} p="xl"
> withBorder
Antrian style={{
</Badge> background:
</Flex> "linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))",
<Divider my={0} /> borderColor: "rgba(100,100,100,0.2)",
<Stack gap="sm"> boxShadow: "0 0 20px rgba(0,255,200,0.08)",
<Flex direction={"column"} justify="flex-start"> }}
<Group gap="xs"> >
<IconClockHour3 size={20} color="white" /> <Stack gap="md">
<Text size="md" c="white"> <Flex align="center" justify="space-between">
Tanggal Aduan <Flex direction={"column"}>
</Text> <Title order={3} c="gray.2">
</Group> {v.title}
<Text size="md"> </Title>
05 November 2025 <Group>
</Text> <Title order={6} c="gray.5">
</Flex> #{v.noPengaduan}
<Flex direction={"column"} justify="flex-start"> </Title>
<Group gap="xs"> <Text size="sm" c="dimmed">
<IconMapPin size={20} color="white" /> {v.updatedAt}
<Text size="md" c="white"> </Text>
Lokasi </Group>
</Text> </Flex>
</Group> <Badge
<Text size="md"> size="xl"
Jalan Darmasaba Raya no 77 variant="light"
</Text> radius="sm"
</Flex> color={v.status === "diterima" ? "green" : v.status === "ditolak" ? "red" : v.status === "selesai" ? "blue" : v.status === "dikerjakan" ? "purple" : "yellow"}
<Flex direction={"column"} justify="flex-start"> style={{ textTransform: "none" }}
<Group gap="xs"> >
<IconAlignJustified size={20} color="white" /> {v.status}
<Text size="md" c="white"> </Badge>
Detail </Flex>
</Text> <Divider my={0} />
</Group> <Stack gap="sm">
<Text size="md"> <Flex direction={"column"} justify="flex-start">
Lorem, ipsum dolor sit amet consectetur adipisicing elit. Quis, obcaecati. Sint natus culpa temporibus neque quasi expedita ratione, facere optio incidunt quibusdam suscipit nam nemo delectus beatae similique velit obcaecati? <Group gap="xs">
</Text> <IconClockHour3 size={20} color="white" />
</Flex> <Text size="md" c="white">
</Stack> Tanggal Aduan
</Stack> </Text>
</Card> </Group>
<Text size="md">
{v.createdAt}
</Text>
</Flex>
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconMapPin size={20} color="white" />
<Text size="md" c="white">
Lokasi
</Text>
</Group>
<Text size="md">
{v.location}
</Text>
</Flex>
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconAlignJustified size={20} color="white" />
<Text size="md" c="white">
Detail
</Text>
</Group>
<Text size="md">
{v.detail}
</Text>
</Flex>
</Stack>
</Stack>
</Card>
))}
</Stack>
); );
} }

View File

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

View File

@@ -1,5 +1,6 @@
import Elysia, { t } from "elysia" import Elysia, { t } from "elysia"
import type { StatusPengaduan } from "generated/prisma" import type { StatusPengaduan } from "generated/prisma"
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"
@@ -462,7 +463,137 @@ const PengaduanRoute = new Elysia({
tags: ["mcp"], tags: ["mcp"],
consumes: ["multipart/form-data"] 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 !== "all") {
where = {
...where,
status: status
}
}
const data = await prisma.pengaduan.findMany({
skip,
take: !take ? 10 : Number(take),
orderBy: {
createdAt: "asc"
},
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,
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`,
}
})
;
export default PengaduanRoute export default PengaduanRoute