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:
@@ -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 () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user