Merge pull request 'upd:' (#30) from amalia/18-nov-25 into main

Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/30
This commit is contained in:
2025-11-18 17:38:05 +08:00
6 changed files with 670 additions and 215 deletions

View File

@@ -184,8 +184,8 @@ model SuratPelayanan {
Warga Warga @relation(fields: [idWarga], references: [id]) Warga Warga @relation(fields: [idWarga], references: [id])
idWarga String idWarga String
noSurat String noSurat String
dateExpired DateTime @db.Date dateExpired DateTime? @db.Date
status Int status Int @default(0)
isActive Boolean @default(true) isActive Boolean @default(true)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt

View File

@@ -1,6 +1,6 @@
import notification from "@/components/notificationGlobal";
import apiFetch from "@/lib/apiFetch"; import apiFetch from "@/lib/apiFetch";
import { import {
Anchor,
Badge, Badge,
Button, Button,
Card, Card,
@@ -9,89 +9,160 @@ import {
Flex, Flex,
Grid, Grid,
Group, Group,
List,
Modal, Modal,
Stack, Stack,
Table, Table,
Text, Text,
Textarea, Textarea,
Title, ThemeIcon,
Title
} from "@mantine/core"; } from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks"; import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import { import {
IconAlignJustified, IconAlignJustified,
IconCategory, IconCheck,
IconFileCertificate, IconFileCertificate,
IconInfoTriangle, IconFileCheck,
IconMapPin,
IconMessageReport, IconMessageReport,
IconPhotoScan, IconPhone,
IconUser, IconUser
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useState } from "react"; import type { User } from "generated/prisma";
import _ from "lodash";
import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import useSwr from "swr"; import useSwr from "swr";
export default function DetailPelayananPage() { export default function DetailPengajuanPage() {
const { search } = useLocation(); const { search } = useLocation();
const query = new URLSearchParams(search); const query = new URLSearchParams(search);
const id = query.get("id"); const id = query.get("id");
const { data, mutate, isLoading } = useSwr("/", () =>
apiFetch.api.pelayanan.detail.get({
query: {
id: id!,
},
}),
);
useShallowEffect(() => {
mutate();
}, []);
return ( return (
<Container size="xl" py="xl" w={"100%"}> <Container size="xl" py="xl" w={"100%"}>
<Grid> <Grid>
<Grid.Col span={8}> <Grid.Col span={8}>
<Stack gap={"xl"}> <Stack gap={"xl"}>
<DetailDataPelayanan /> <DetailDataPengajuan data={data?.data?.pengajuan} syaratDokumen={data?.data?.syaratDokumen} dataText={data?.data?.dataText} onAction={() => { mutate(); }} />
<DetailDataHistori /> <DetailDataHistori data={data?.data?.history} />
</Stack> </Stack>
</Grid.Col> </Grid.Col>
<Grid.Col span={4}> <Grid.Col span={4}>
<DetailUserPelayanan /> <DetailUserPengajuan data={data?.data?.warga} />
</Grid.Col> </Grid.Col>
</Grid> </Grid>
</Container> </Container>
); );
} }
function DetailDataPelayanan() { function DetailDataPengajuan({ data, syaratDokumen, dataText, onAction }: { data: any, syaratDokumen: any, dataText: any, onAction: () => void }) {
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [catModal, setCatModal] = useState<"tolak" | "terima">("tolak"); const [catModal, setCatModal] = useState<"tolak" | "terima">("tolak");
const [keterangan, setKeterangan] = useState("");
const [host, setHost] = useState<User | null>(null);
const [noSurat, setNoSurat] = useState("");
useEffect(() => {
async function fetchHost() {
const { data } = await apiFetch.api.user.find.get();
setHost(data?.user ?? null);
}
fetchHost();
}, []);
const handleKonfirmasi = async (cat: "terima" | "tolak") => {
try {
const res = await apiFetch.api.pelayanan["update-status"].post({
id: data?.id,
status: cat == 'tolak' ? 'ditolak' : data.status == 'antrian' ? 'diterima' : 'selesai',
keterangan: keterangan,
idUser: host?.id ?? "",
noSurat: noSurat
});
if (res?.status === 200) {
onAction();
close();
notification({
title: "Success",
message: "Success update pengajuan surat",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to update pengajuan surat",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to update pengajuan surat",
type: "error",
});
}
}
return ( return (
<> <>
{/* MODAL KONFIRMASI */}
<Modal <Modal
opened={opened} opened={opened}
onClose={close} onClose={close}
title={"Konfirmasi"} title={"Konfirmasi"}
centered
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }} overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
> >
<Stack gap="sm"> <Stack gap="sm">
{catModal === "tolak" ? ( {catModal === "tolak" ? (
<> <>
<Text> <Text>
Anda yakin ingin menolak pengaduan ini? Berikan alasan penolakan Anda yakin ingin menolak pengajuan surat ini? Berikan alasan penolakan
</Text> </Text>
<Textarea size="md" minRows={5} value={keterangan} onChange={(e) => setKeterangan(e.target.value)} />
<Textarea size="md" minRows={5} />
<Group justify="center" grow> <Group justify="center" grow>
<Button variant="light" onClick={close}> <Button variant="light" onClick={close}>
Batal Batal
</Button> </Button>
<Button variant="filled" color="red" onClick={close}> <Button variant="filled" color="red" disabled={keterangan.length < 1} onClick={() => handleKonfirmasi("tolak")}>
Tolak Tolak
</Button> </Button>
</Group> </Group>
</> </>
) : ( ) : (
<> <>
<Text>Anda yakin ingin menerima pengaduan ini?</Text> <Text>
Anda yakin ingin {data?.status == 'antrian' ? 'menerima' : 'menyetujui'} pengajuan surat ini?
{
data.status == 'diterima' && 'Masukkan nomer surat yang akan dibuat'
}
</Text>
{
data.status == 'diterima' && (
<Textarea size="md" minRows={5} value={noSurat} onChange={(e) => setNoSurat(e.target.value)} placeholder="Contoh : 08/D-IV/11/2025" />
)
}
<Group justify="center" grow> <Group justify="center" grow>
<Button variant="light" onClick={close}> <Button variant="light" onClick={close}>
Batal Tidak
</Button> </Button>
<Button variant="filled" color="green" onClick={close}> <Button variant="filled" color="green" onClick={() => handleKonfirmasi("terima")} disabled={data.status == 'diterima' && noSurat.length < 1}>
Terima Ya
</Button> </Button>
</Group> </Group>
</> </>
@@ -114,117 +185,141 @@ function DetailDataPelayanan() {
<Flex align="center" justify="space-between"> <Flex align="center" justify="space-between">
<Group gap="xs"> <Group gap="xs">
<Title order={4} c="gray.2"> <Title order={4} c="gray.2">
Pelayanan Surat Pengajuan {data?.category}
</Title> </Title>
<Title order={4} c="dimmed"> <Title order={4} c="dimmed">
#PGf-2345-33 #{data?.noPengajuan}
</Title> </Title>
</Group> </Group>
<Badge <Badge
size="xl" size="xl"
variant="light" variant="light"
radius="sm" radius="sm"
color={"yellow"} color={
data?.status === "diterima"
? "green"
: data?.status === "ditolak"
? "red"
: data?.status === "selesai"
? "blue"
: data?.status === "dikerjakan"
? "gray"
: "yellow"
}
style={{ textTransform: "none" }} style={{ textTransform: "none" }}
> >
antrian {data?.status}
</Badge> </Badge>
</Flex> </Flex>
<Divider my={0} /> <Divider my={0} />
<Grid> <Grid>
<Grid.Col span={6}> <Grid.Col span={12}>
<Stack gap="md"> <Stack gap="lg">
<Flex direction={"column"} justify="flex-start">
<Group gap="xs">
<IconFileCheck size={20} />
<Text size="md">Syarat Dokumen</Text>
</Group>
<List
spacing="sm"
pt={10}
icon={
<ThemeIcon color="green" size={20} radius="xl">
<IconCheck size={13} />
</ThemeIcon>
}
>
{syaratDokumen?.map((v: any) => (
<List.Item key={v.id}>{v.jenis}</List.Item>
))}
</List>
</Flex>
<Flex direction={"column"} justify="flex-start"> <Flex direction={"column"} justify="flex-start">
<Group gap="xs"> <Group gap="xs">
<IconAlignJustified size={20} /> <IconAlignJustified size={20} />
<Text size="md">Judul</Text> <Text size="md">Data Pelengkap</Text>
</Group> </Group>
<Text size="md" c={"white"}>
Judul Pelayanan Surat <Table withRowBorders={false}>
</Text> <Table.Tbody>
</Flex> {
<Flex direction={"column"} justify="flex-start"> dataText?.map((item: any) => (
<Group gap="xs"> <Table.Tr key={item.id}>
<IconMapPin size={20} /> <Table.Td style={{ whiteSpace: "nowrap", width: "10%" }}>{_.upperFirst(item.jenis)}</Table.Td>
<Text size="md">Lokasi</Text> <Table.Td>:</Table.Td>
</Group> <Table.Td style={{ width: "85%" }}>{_.upperFirst(item.value)}</Table.Td>
<Text size="md" c="white"> </Table.Tr>
fwef ))
</Text> }
</Flex> </Table.Tbody>
</Stack> </Table>
</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> </Flex>
</Stack> </Stack>
</Grid.Col> </Grid.Col>
<Grid.Col span={12}> <Grid.Col span={12}>
<Stack gap="md"> {
<Flex direction={"column"} justify="flex-start"> data?.status === "antrian" ? (
<Group gap="xs"> <Group justify="center" grow>
<IconAlignJustified size={20} /> <Button
<Text size="md">Detail</Text> variant="light"
onClick={() => {
setCatModal("tolak");
open();
}}
>
Tolak
</Button>
<Button
variant="filled"
onClick={() => {
setCatModal("terima");
open();
}}
>
Terima
</Button>
</Group> </Group>
<Text size="md" c="white"> ) : data?.status === "diterima" ? (
Lorem ipsum dolor sit, amet consectetur adipisicing elit. <Group justify="center" grow>
Illum, corporis iusto. Suscipit veritatis quas, non nobis <Button
fuga, laudantium accusantium tempora sint aliquid architecto variant="light"
totam esse eum excepturi nostrum fugiat ut. onClick={() => {
</Text> setCatModal("tolak");
</Flex> open();
<Flex direction={"column"} justify="flex-start"> }}
<Group gap="xs"> >
<IconInfoTriangle size={20} /> Tolak
<Text size="md">Keterangan</Text> </Button>
<Button
variant="filled"
onClick={() => {
setCatModal("terima");
open();
}}
>
Setujui
</Button>
</Group> </Group>
<Text size="md" c={"white"}> ) : (
Lorem ipsum dolor, sit amet consectetur adipisicing elit. At <Group justify="center" grow>
fugiat eligendi nesciunt dolore? Maiores a cumque vitae <Button
suscipit incidunt quos beatae modi, vel, id ullam quae variant="light"
voluptas, deserunt quas placeat. onClick={() => { }}
</Text> >
</Flex> Lihat Surat
</Stack> </Button>
</Grid.Col> <Button
<Grid.Col span={12}> variant="light"
<Group justify="center" grow> onClick={() => { }}
<Button >
variant="light" Download
onClick={() => { </Button>
setCatModal("tolak"); </Group>
open(); )
}} }
>
Tolak
</Button>
<Button
variant="filled"
onClick={() => {
setCatModal("terima");
open();
}}
>
Terima
</Button>
</Group>
</Grid.Col> </Grid.Col>
</Grid> </Grid>
</Stack> </Stack>
@@ -233,23 +328,7 @@ function DetailDataPelayanan() {
); );
} }
function DetailDataHistori() { function DetailDataHistori({ data }: { data: any }) {
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 ( return (
<Card <Card
radius="md" radius="md"
@@ -265,7 +344,7 @@ function DetailDataHistori() {
<Stack gap="md"> <Stack gap="md">
<Flex align="center" justify="space-between"> <Flex align="center" justify="space-between">
<Title order={4} c="gray.2"> <Title order={4} c="gray.2">
Histori Pengaduan Histori Pengajuan Surat
</Title> </Title>
</Flex> </Flex>
<Divider my={0} /> <Divider my={0} />
@@ -278,33 +357,25 @@ function DetailDataHistori() {
<Table.Th>User</Table.Th> <Table.Th>User</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody>{rows}</Table.Tbody> <Table.Tbody>
{
data?.map((item: any) => (
<Table.Tr key={item.id}>
<Table.Td style={{ whiteSpace: "nowrap" }}>{item.createdAt}</Table.Td>
<Table.Td>{item.deskripsi}</Table.Td>
<Table.Td>{item.status}</Table.Td>
<Table.Td style={{ whiteSpace: "nowrap" }}>{item.nameUser ? item.nameUser : "-"}</Table.Td>
</Table.Tr>
))
}
</Table.Tbody>
</Table> </Table>
</Stack> </Stack>
</Card> </Card>
); );
} }
function DetailUserPelayanan() { function DetailUserPengajuan({ data }: { data: any }) {
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 ( return (
<Card <Card
radius="md" radius="md"
@@ -333,16 +404,16 @@ function DetailUserPelayanan() {
<Text size="md">Nama</Text> <Text size="md">Nama</Text>
</Group> </Group>
<Text size="md" c={"white"}> <Text size="md" c={"white"}>
Amalia Dwi Yustiani {data?.name}
</Text> </Text>
</Group> </Group>
<Group justify="space-between"> <Group justify="space-between">
<Group gap="xs"> <Group gap="xs">
<IconMapPin size={20} /> <IconPhone size={20} />
<Text size="md">Telepon</Text> <Text size="md">Telepon</Text>
</Group> </Group>
<Text size="md" c="white"> <Text size="md" c="white">
08123456789 {data?.phone}
</Text> </Text>
</Group> </Group>
<Group justify="space-between"> <Group justify="space-between">
@@ -351,7 +422,7 @@ function DetailUserPelayanan() {
<Text size="md">Jumlah Pengaduan</Text> <Text size="md">Jumlah Pengaduan</Text>
</Group> </Group>
<Text size="md" c="white"> <Text size="md" c="white">
10 {data?.pengaduan}
</Text> </Text>
</Group> </Group>
<Group justify="space-between"> <Group justify="space-between">
@@ -360,7 +431,7 @@ function DetailUserPelayanan() {
<Text size="md">Jumlah Pelayanan Surat</Text> <Text size="md">Jumlah Pelayanan Surat</Text>
</Group> </Group>
<Text size="md" c="white"> <Text size="md" c="white">
10 {data?.pelayanan}
</Text> </Text>
</Group> </Group>
</Stack> </Stack>

View File

@@ -15,16 +15,15 @@ import {
} from "@mantine/core"; } from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks"; import { useShallowEffect } from "@mantine/hooks";
import { import {
IconAlignJustified,
IconClockHour3, IconClockHour3,
IconFileSad, IconFileSad,
IconMapPin,
IconSearch, IconSearch,
IconUser
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { useState } from "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, subscribe } from "valtio";
const state = proxy({ reload: "" }); const state = proxy({ reload: "" });
function reloadState() { function reloadState() {
@@ -49,14 +48,14 @@ export default function PelayananSuratListPage() {
function TabListPelayananSurat({ status }: { status: string }) { function TabListPelayananSurat({ status }: { status: string }) {
const navigate = useNavigate(); const navigate = useNavigate();
const dataCount = useSwr("/pelayanan-surat/count", () => const dataCount = useSwr("/pelayanan-surat/count", () =>
apiFetch.api.pengaduan.count.get().then((res) => res.data), apiFetch.api.pelayanan.count.get().then((res) => res.data),
); );
return ( return (
<Tabs defaultValue={status || "semua"} color="teal"> <Tabs defaultValue={status || "semua"} color="teal">
<Tabs.List grow> <Tabs.List grow>
<Tabs.Tab <Tabs.Tab
value="all" value="semua"
onClick={() => { onClick={() => {
navigate("?status=semua"); navigate("?status=semua");
}} }}
@@ -118,21 +117,29 @@ type StatusKey =
function ListPelayananSurat({ status }: { status: StatusKey }) { function ListPelayananSurat({ status }: { status: StatusKey }) {
const [page, setPage] = useState(1); const [page, setPage] = useState(1);
const [value, setValue] = useState(""); const [value, setValue] = useState("");
const { data, mutate, isLoading } = useSwr("/", () => const { data, mutate, isLoading } = useSwr("/", async () => {
apiFetch.api.pengaduan.list.get({ const res = await apiFetch.api.pelayanan.list.get({
query: { query: {
status, status,
search: value, search: value,
take: "", take: "",
page: "", page: "",
}, },
}), });
);
return Array.isArray(res?.data) ? res.data : []; // ⬅ paksa return array
});
useShallowEffect(() => { useShallowEffect(() => {
mutate(); mutate();
}, [status, value]); }, [status, value]);
useShallowEffect(() => {
const unsubscribe = subscribe(state, () => mutate());
return () => unsubscribe();
}, []);
const navigate = useNavigate(); const navigate = useNavigate();
if (isLoading) if (isLoading)
@@ -147,19 +154,19 @@ function ListPelayananSurat({ status }: { status: StatusKey }) {
}} }}
> >
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
Loading pengaduan... Loading pelayanan surat...
</Text> </Text>
</Card> </Card>
); );
const list = data?.data || []; const list = data || [];
return ( return (
<Stack gap="xl"> <Stack gap="xl">
<Group grow> <Group grow>
<Input <Input
value={value} value={value}
placeholder="Cari pengaduan..." placeholder="Cari pengajuan..."
onChange={(event) => setValue(event.currentTarget.value)} onChange={(event) => setValue(event.currentTarget.value)}
leftSection={<IconSearch size={16} />} leftSection={<IconSearch size={16} />}
rightSectionPointerEvents="all" rightSectionPointerEvents="all"
@@ -204,11 +211,11 @@ function ListPelayananSurat({ status }: { status: StatusKey }) {
<Flex align="center" justify="space-between"> <Flex align="center" justify="space-between">
<Flex direction={"column"}> <Flex direction={"column"}>
<Title order={3} c="gray.2"> <Title order={3} c="gray.2">
{v.title} {v.category}
</Title> </Title>
<Group> <Group>
<Title order={6} c="gray.5"> <Title order={6} c="gray.5">
#{v.noPengaduan} #{v.noPengajuan}
</Title> </Title>
<Text size="sm" c="dimmed"> <Text size="sm" c="dimmed">
{v.updatedAt} {v.updatedAt}
@@ -227,7 +234,7 @@ function ListPelayananSurat({ status }: { status: StatusKey }) {
: v.status === "selesai" : v.status === "selesai"
? "blue" ? "blue"
: v.status === "dikerjakan" : v.status === "dikerjakan"
? "purple" ? "gray"
: "yellow" : "yellow"
} }
style={{ textTransform: "none" }} style={{ textTransform: "none" }}
@@ -241,28 +248,19 @@ function ListPelayananSurat({ status }: { status: StatusKey }) {
<Group gap="xs"> <Group gap="xs">
<IconClockHour3 size={20} color="white" /> <IconClockHour3 size={20} color="white" />
<Text size="md" c="white"> <Text size="md" c="white">
Tanggal Aduan Tanggal Ajuan
</Text> </Text>
</Group> </Group>
<Text size="md">{v.createdAt}</Text> <Text size="md">{v.createdAt}</Text>
</Flex> </Flex>
<Flex direction={"column"} justify="flex-start"> <Flex direction={"column"} justify="flex-start">
<Group gap="xs"> <Group gap="xs">
<IconMapPin size={20} color="white" /> <IconUser size={20} color="white" />
<Text size="md" c="white"> <Text size="md" c="white">
Lokasi Warga
</Text> </Text>
</Group> </Group>
<Text size="md">{v.location}</Text> <Text size="md">{v.warga}</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> </Flex>
</Stack> </Stack>
</Stack> </Stack>

View File

@@ -0,0 +1,24 @@
import { prisma } from "./prisma"
export async function createSurat({ idPengajuan, idCategory, idWarga, noSurat }: { idPengajuan: string, idCategory: string, idWarga: string, noSurat: string }) {
try {
const surat = await prisma.suratPelayanan.create({
data: {
idPengajuanLayanan: idPengajuan,
idCategory,
idWarga,
noSurat,
}
})
if (!surat.id) {
return { success: false, message: 'gagal membuat surat' }
}
return { success: true, message: 'surat sudah dibuat' }
} catch (error) {
console.log(error)
return { success: false, message: 'gagal membuat surat' }
}
}

View File

@@ -1,5 +1,7 @@
import Elysia, { t } from "elysia" import Elysia, { t } from "elysia"
import type { StatusPengaduan } from "generated/prisma" import type { StatusPengaduan } from "generated/prisma"
import { createSurat } from "../lib/create-surat"
import { getLastUpdated } from "../lib/get-last-updated"
import { generateNoPengajuanSurat } from "../lib/no-pengajuan-surat" import { generateNoPengajuanSurat } from "../lib/no-pengajuan-surat"
import { normalizePhoneNumber } from "../lib/normalizePhone" import { normalizePhoneNumber } from "../lib/normalizePhone"
import { prisma } from "../lib/prisma" import { prisma } from "../lib/prisma"
@@ -102,28 +104,179 @@ const PelayananRoute = new Elysia({
// --- PELAYANAN SURAT --- // --- PELAYANAN SURAT ---
.get("/", async () => { .get("/", async ({ query }) => {
const { phone } = query
const data = await prisma.pelayananAjuan.findMany({ const data = await prisma.pelayananAjuan.findMany({
orderBy: {
createdAt: "asc"
},
where: { where: {
isActive: true isActive: true,
Warga: {
phone
}
} }
}) })
return data return data
}, { }, {
query: t.Object({
phone: t.String({ minLength: 1, error: "phone harus diisi" }),
}),
detail: { detail: {
summary: "List Ajuan Pelayanan Surat", summary: "List Ajuan Pelayanan Surat by Phone",
description: `tool untuk mendapatkan list ajuan pelayanan surat`, description: `tool untuk mendapatkan list ajuan pelayanan surat`,
tags: ["mcp"] tags: ["mcp"]
} }
}) })
.get("/detail", async ({ query }) => { .get("/detail", async ({ query }) => {
const { id } = query const { id } = query
const data = await prisma.pelayananAjuan.findUnique({
const data = await prisma.pelayananAjuan.findFirst({
where: { where: {
id, OR: [
{
noPengajuan: id
},
{
id: id
}
]
},
select: {
id: true,
noPengajuan: true,
status: true,
createdAt: true,
updatedAt: true,
CategoryPelayanan: {
select: {
name: true,
dataText: true,
syaratDokumen: true,
}
},
Warga: {
select: {
name: true,
phone: true,
_count: {
select: {
Pengaduan: true,
PelayananAjuan: true,
}
}
}
},
} }
}) })
return data
const dataSyarat = await prisma.syaratDokumenPelayanan.findMany({
where: {
idPengajuanLayanan: data?.id,
isActive: true
},
select: {
id: true,
jenis: true,
value: true,
}
})
const dataText = await prisma.dataTextPelayanan.findMany({
where: {
idPengajuanLayanan: data?.id,
isActive: true
},
select: {
id: true,
value: true,
jenis: true,
}
})
const syaratDokumen = (data?.CategoryPelayanan?.syaratDokumen ?? []) as {
name: string;
desc: string;
}[];
const dataSyaratFix = dataSyarat.map((item) => {
const desc = syaratDokumen.find((v) => v.name == item.jenis)?.desc
return {
id: item.id,
jenis: desc,
value: item.value,
}
})
const dataTextFix = dataText.map((item) => {
const desc = data?.CategoryPelayanan?.dataText.find((v) => v == item.jenis)
return {
id: item.id,
jenis: item.jenis,
value: item.value,
}
})
const dataHistory = await prisma.historyPelayanan.findMany({
where: {
idPengajuanLayanan: data?.id,
},
select: {
id: true,
deskripsi: true,
status: true,
createdAt: true,
idUser: true,
User: {
select: {
name: true,
}
}
}
})
const dataHistoryFix = dataHistory.map((item) => {
return {
id: item.id,
deskripsi: item.deskripsi,
status: item.status,
createdAt: item.createdAt.toLocaleString("id-ID", {
day: "2-digit",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
hour12: false
}),
idUser: item.idUser,
nameUser: item.User?.name,
}
})
const warga = {
name: data?.Warga?.name,
phone: data?.Warga?.phone,
pengaduan: data?.Warga?._count.Pengaduan,
pelayanan: data?.Warga?._count.PelayananAjuan,
}
const dataPengajuan = {
id: data?.id,
noPengajuan: data?.noPengajuan,
category: data?.CategoryPelayanan.name,
status: data?.status,
createdAt: data?.createdAt,
updatedAt: data?.updatedAt,
}
const datafix = {
pengajuan: dataPengajuan,
history: dataHistoryFix,
warga: warga,
syaratDokumen: dataSyaratFix,
dataText: dataTextFix,
}
return datafix
}, { }, {
query: t.Object({ query: t.Object({
id: t.String({ minLength: 1, error: "id harus diisi" }), id: t.String({ minLength: 1, error: "id harus diisi" }),
@@ -135,20 +288,20 @@ const PelayananRoute = new Elysia({
} }
}) })
.post("/create", async ({ body }) => { .post("/create", async ({ body }) => {
const { idCategory, idWarga, phone, dataText, syaratDokumen } = body const { kategoriId, wargaId, noTelepon, dataText, syaratDokumen } = body
const noPengajuan = await generateNoPengajuanSurat() const noPengajuan = await generateNoPengajuanSurat()
let idCategoryFix = idCategory let idCategoryFix = kategoriId
let idWargaFix = idWarga let idWargaFix = wargaId
const category = await prisma.categoryPelayanan.findUnique({ const category = await prisma.categoryPelayanan.findUnique({
where: { where: {
id: idCategory, id: kategoriId,
} }
}) })
if (!category) { if (!category) {
const cariCategory = await prisma.categoryPelayanan.findFirst({ const cariCategory = await prisma.categoryPelayanan.findFirst({
where: { where: {
name: idCategory, name: kategoriId,
} }
}) })
@@ -162,12 +315,12 @@ const PelayananRoute = new Elysia({
const warga = await prisma.warga.findUnique({ const warga = await prisma.warga.findUnique({
where: { where: {
id: idWarga, id: wargaId,
} }
}) })
if (!warga) { if (!warga) {
const nomorHP = normalizePhoneNumber({ phone }) const nomorHP = normalizePhoneNumber({ phone: noTelepon })
const cariWarga = await prisma.warga.findFirst({ const cariWarga = await prisma.warga.findFirst({
where: { where: {
phone: nomorHP, phone: nomorHP,
@@ -177,7 +330,7 @@ const PelayananRoute = new Elysia({
if (!cariWarga) { if (!cariWarga) {
const wargaCreate = await prisma.warga.create({ const wargaCreate = await prisma.warga.create({
data: { data: {
name: idWarga, name: wargaId,
phone: nomorHP, phone: nomorHP,
}, },
select: { select: {
@@ -203,7 +356,7 @@ const PelayananRoute = new Elysia({
}) })
if (!pengaduan.id) { if (!pengaduan.id) {
throw new Error("gagal membuat pengajuan surat") return { success: false, message: 'gagal membuat pengajuan surat' }
} }
let dataInsertSyaratDokumen = [] let dataInsertSyaratDokumen = []
@@ -246,17 +399,81 @@ const PelayananRoute = new Elysia({
return { success: true, message: 'pengajuan surat sudah dibuat' } return { success: true, message: 'pengajuan surat sudah dibuat' }
}, { }, {
body: t.Object({ body: t.Object({
idCategory: t.String({ minLength: 1, error: "idCategory harus diisi" }), kategoriId: t.String({
idWarga: t.String({ minLength: 1, error: "idWarga harus diisi" }), minLength: 1,
phone: t.String({ minLength: 1, error: "phone harus diisi" }), description: "ID atau nama kategori pelayanan surat yang dipilih. Jika berupa nama, sistem akan mencocokkan secara otomatis.",
dataText: t.Array(t.Object({ examples: ["skusaha"],
jenis: t.String({ minLength: 1, error: "jenis harus diisi" }), error: "ID kategori harus diisi"
value: t.String({ minLength: 1, error: "value harus diisi" }), }),
})),
syaratDokumen: t.Array(t.Object({ wargaId: t.String({
jenis: t.String({ minLength: 1, error: "jenis harus diisi" }), minLength: 1,
value: t.String({ minLength: 1, error: "value harus diisi" }), description: "ID warga atau nama warga. Jika ID tidak ditemukan, sistem akan mencari berdasarkan nama.",
})), examples: ["Budi Santoso"],
error: "ID warga harus diisi"
}),
noTelepon: t.String({
minLength: 8,
description: "Nomor HP warga yang akan dinormalisasi. Jika data warga tidak ditemukan berdasarkan idWarga, pencarian dilakukan via nomor ini.",
examples: ["081234567890"],
error: "Nomor telepon harus diisi"
}),
dataText: t.Array(
t.Object({
jenis: t.String({
minLength: 1,
description: "Jenis field yang dibutuhkan oleh kategori pelayanan. Biasanya dinamis.",
examples: ["nama", "alamat", "pekerjaan", "keperluan"],
error: "jenis harus diisi"
}),
value: t.String({
minLength: 1,
description: "Isi atau nilai dari jenis field terkait.",
examples: ["Budi Santoso", "Jl. Mawar No. 10", "Karyawan Swasta"],
error: "value harus diisi"
}),
}),
{
description: "Kumpulan data text dinamis sesuai kategori layanan.",
examples: [
[
{ jenis: "jenis usaha", value: "usaha makanan" },
{ jenis: "alamat usaha", value: "Jl. Melati No. 21" },
]
],
error: "dataText harus berupa array"
}
),
syaratDokumen: t.Array(
t.Object({
jenis: t.String({
minLength: 1,
description: "Jenis dokumen persyaratan yang diminta oleh kategori layanan.",
examples: ["ktp", "kk", "surat_pengantar_rt"],
error: "jenis harus diisi"
}),
value: t.String({
minLength: 1,
description: "Nama file atau identifier file dokumen yang diupload.",
examples: ["ktp_budi.png", "kk_budi.png"],
error: "value harus diisi"
}),
}),
{
description: "Kumpulan dokumen yang wajib diupload sesuai persyaratan layanan.",
examples: [
[
{ jenis: "pengantar kelian", value: "pengantar_kelurahan_budi.png" },
{ jenis: "ktp/kk", value: "kk_budi.png" },
{ jenis: "foto lokasi", value: "foto_lokasi_budi.png" }
]
],
error: "syaratDokumen harus berupa array"
}
),
}), }),
detail: { detail: {
summary: "Create Pengajuan Pelayanan Surat", summary: "Create Pengajuan Pelayanan Surat",
@@ -265,7 +482,9 @@ const PelayananRoute = new Elysia({
} }
}) })
.post("/update-status", async ({ body }) => { .post("/update-status", async ({ body }) => {
const { id, status, keterangan, idUser } = body const { id, status, keterangan, idUser, noSurat } = body
let deskripsi = ""
const pengajuan = await prisma.pelayananAjuan.update({ const pengajuan = await prisma.pelayananAjuan.update({
where: { where: {
@@ -273,28 +492,48 @@ const PelayananRoute = new Elysia({
}, },
data: { data: {
status: status as StatusPengaduan, status: status as StatusPengaduan,
},
select: {
id: true,
idCategory: true,
idWarga: true,
} }
}) })
if (!pengajuan) { if (!pengajuan) {
throw new Error("gagal membuat pengajuan") return { success: false, message: 'gagal update status pengajuan surat' }
}
if (status === "diterima") {
deskripsi = "Pengajuan surat diterima"
} else if (status === "ditolak") {
deskripsi = "Pengajuan surat ditolak dengan keterangan " + keterangan
} else if (status === "selesai") {
deskripsi = "Pengajuan surat disetujui"
} }
await prisma.historyPelayanan.create({ await prisma.historyPelayanan.create({
data: { data: {
idPengajuanLayanan: pengajuan.id, idPengajuanLayanan: pengajuan.id,
deskripsi: "Pengajuan surat diperbarui", deskripsi: deskripsi,
status: status as StatusPengaduan,
idUser,
keteranganAlasan: keterangan, keteranganAlasan: keterangan,
} }
}) })
if (status === "selesai") {
await createSurat({ idPengajuan: pengajuan.id, idCategory: pengajuan.idCategory, idWarga: pengajuan.idWarga, noSurat })
}
return { success: true, message: 'pengajuan surat sudah diperbarui' } return { success: true, message: 'pengajuan surat sudah diperbarui' }
}, { }, {
body: t.Object({ body: t.Object({
id: t.String({ minLength: 1, error: "id harus diisi" }), id: t.String({ minLength: 1, error: "id harus diisi" }),
status: t.String({ minLength: 1, error: "status harus diisi" }), status: t.String({ minLength: 1, error: "status harus diisi" }),
keterangan: t.String({ minLength: 1, error: "keterangan harus diisi" }), keterangan: t.String({ optional: true }),
idUser: t.String({ minLength: 1, error: "idUser harus diisi" }), idUser: t.String({ optional: true }),
noSurat: t.String({ optional: true }),
}), }),
detail: { detail: {
summary: "Update Status Pengajuan Pelayanan Surat", summary: "Update Status Pengajuan Pelayanan Surat",
@@ -302,5 +541,128 @@ const PelayananRoute = new Elysia({
tags: ["mcp"] tags: ["mcp"]
} }
}) })
.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: [
{
CategoryPelayanan: {
name: {
contains: search ?? "",
mode: "insensitive"
},
},
},
{
noPengajuan: {
contains: search ?? "",
mode: "insensitive"
},
},
{
Warga: {
phone: {
contains: search ?? "",
mode: "insensitive"
},
},
}
]
}
if (status && status !== "semua") {
where = {
...where,
status: status
}
}
const data = await prisma.pelayananAjuan.findMany({
skip,
take: !take ? 10 : Number(take),
orderBy: {
createdAt: "desc"
},
where,
select: {
id: true,
noPengajuan: true,
status: true,
createdAt: true,
updatedAt: true,
CategoryPelayanan: {
select: {
name: true
}
},
Warga: {
select: {
name: true,
}
}
}
})
const dataFix = data.map((item) => {
return {
noPengajuan: item.noPengajuan,
id: item.id,
category: item.CategoryPelayanan.name,
warga: item.Warga.name,
status: item.status,
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 Pengajuan Pelayanan Surat Warga",
description: `tool untuk mendapatkan list pengajuan pelayanan surat warga`,
}
})
.get("/count", async ({ query }) => {
const counts = await prisma.pelayananAjuan.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.pelayananAjuan.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 Pengajuan Pelayanan Surat Warga",
description: `tool untuk mendapatkan jumlah pengajuan pelayanan surat warga`,
}
})
export default PelayananRoute export default PelayananRoute

View File

@@ -278,7 +278,7 @@ Respon:
}) })
if (!pengaduan) { if (!pengaduan) {
throw new Error("gagal membuat pengaduan") return { success: false, message: 'gagal update status pengaduan' }
} }
if (status === "diterima") { if (status === "diterima") {