Merge pull request 'upd: update data pelayanan surat' (#92) from amalia/19-des-25 into main

Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/92
This commit is contained in:
2025-12-19 17:28:49 +08:00
7 changed files with 1121 additions and 595 deletions

View File

@@ -10,6 +10,7 @@ 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 UpdateDataSurat from "./pages/darmasaba/update_data_surat";
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";
@@ -69,6 +70,10 @@ export default function AppRoutes() {
path="/darmasaba/surat-keterangan-domisili-organisasi"
element={<FormSuratKeteranganDomisiliOrganisasi />}
/>
<Route
path="/darmasaba/update-data-surat"
element={<UpdateDataSurat />}
/>
<Route
path="/darmasaba/surat-keterangan-belum-kawin"
element={<FormSuratKeteranganBelumKawin />}

View File

@@ -10,6 +10,7 @@ const clientRoutes = {
"/darmasaba/laporan-sampah": "/darmasaba/laporan-sampah",
"/darmasaba/surat-keterangan-penghasilan": "/darmasaba/surat-keterangan-penghasilan",
"/darmasaba/surat-keterangan-domisili-organisasi": "/darmasaba/surat-keterangan-domisili-organisasi",
"/darmasaba/update-data-surat": "/darmasaba/update-data-surat",
"/darmasaba/surat-keterangan-belum-kawin": "/darmasaba/surat-keterangan-belum-kawin",
"/darmasaba/keterangan-kelahiran": "/darmasaba/keterangan-kelahiran",
"/darmasaba/surat-keterangan-tempat-usaha": "/darmasaba/surat-keterangan-tempat-usaha",

View File

@@ -1,43 +1,38 @@
import { Center, Loader, Overlay, Stack, Text } from "@mantine/core"
import { Center, Loader, Overlay, Stack, Text } from "@mantine/core";
type FullScreenLoadingProps = {
visible: boolean
text?: string
}
visible: boolean;
text?: string;
};
export default function FullScreenLoading({
visible,
text = "Memproses data..."
visible,
text = "Memproses data...",
}: FullScreenLoadingProps) {
if (!visible) return null
if (!visible) return null;
return (
<Overlay
fixed
blur={6}
backgroundOpacity={0.3}
zIndex={10000}
>
<Center h="100%">
<Stack align="center" justify="center">
<Loader size="lg" />
<Text size="sm" c="dimmed">
{text}
</Text>
</Stack>
</Center>
</Overlay>
)
return (
<Overlay fixed blur={6} backgroundOpacity={0.3} zIndex={10000}>
<Center h="100%">
<Stack align="center" justify="center">
<Loader size="lg" />
<Text size="sm" c="dimmed">
{text}
</Text>
</Stack>
</Center>
</Overlay>
);
}
const overlayStyle: React.CSSProperties = {
position: "fixed",
inset: 0,
zIndex: 9999,
backdropFilter: "blur(6px)",
backgroundColor: "rgba(255, 255, 255, 0.6)"
}
position: "fixed",
inset: 0,
zIndex: 9999,
backdropFilter: "blur(6px)",
backgroundColor: "rgba(255, 255, 255, 0.6)",
};
const contentStyle: React.CSSProperties = {
flexDirection: "column"
}
flexDirection: "column",
};

View File

@@ -1,50 +1,43 @@
import { Badge, Button, Card, Center, Stack, Text, Title } from "@mantine/core"
import { IconCheck } from "@tabler/icons-react"
import { Badge, Button, Card, Center, Stack, Text, Title } from "@mantine/core";
import { IconCheck } from "@tabler/icons-react";
type SuccessPengajuanProps = {
noPengajuan: string
onClose?: () => void
}
noPengajuan: string;
onClose?: () => void;
};
export default function SuccessPengajuan({
noPengajuan,
onClose
noPengajuan,
onClose,
}: SuccessPengajuanProps) {
return (
<Center h="100vh">
<Card
shadow="md"
radius="md"
p="xl"
withBorder
maw={520}
w="100%"
>
<Stack align="center" gap="md">
<IconCheck size={56} color="green" />
return (
<Center h="100vh">
<Card shadow="md" radius="md" p="xl" withBorder maw={520} w="100%">
<Stack align="center" gap="md">
<IconCheck size={56} color="green" />
<Title order={3} ta="center">
Pengajuan Berhasil Dibuat
</Title>
<Title order={3} ta="center">
Pengajuan Berhasil Dibuat
</Title>
<Text ta="center" size="sm" c="dimmed">
Pengajuan layanan surat sudah dibuat dengan nomor:
</Text>
<Text ta="center" size="sm" c="dimmed">
Pengajuan layanan surat sudah dibuat dengan nomor:
</Text>
<Badge size="xl" variant="light" color="green">
{noPengajuan}
</Badge>
<Badge size="xl" variant="light" color="green">
{noPengajuan}
</Badge>
<Text ta="center" size="sm">
Nomor ini akan digunakan untuk mengakses dan memantau status
pengajuan surat Anda.
</Text>
<Text ta="center" size="sm">
Nomor ini akan digunakan untuk mengakses dan memantau status
pengajuan surat Anda.
</Text>
<Button fullWidth mt="md" onClick={onClose}>
Selesai
</Button>
</Stack>
</Card>
</Center>
)
<Button fullWidth mt="md" onClick={onClose}>
Selesai
</Button>
</Stack>
</Card>
</Center>
);
}

View File

@@ -4,493 +4,510 @@ import SuccessPengajuan from "@/components/SuccessPengajuanSurat";
import apiFetch from "@/lib/apiFetch";
import { fromSlug, toSlug } from "@/server/lib/slug_converter";
import {
ActionIcon,
Badge,
Box,
Button,
Card,
Container,
Divider,
FileInput,
Flex,
Grid,
Group,
Select,
Stack,
Text,
TextInput,
Tooltip
ActionIcon,
Badge,
Box,
Button,
Card,
Container,
Divider,
FileInput,
Flex,
Grid,
Group,
Select,
Stack,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks";
import {
IconBuildingCommunity,
IconInfoCircle,
IconUpload,
IconUser
IconBuildingCommunity,
IconInfoCircle,
IconUpload,
IconUser,
} from "@tabler/icons-react";
import React, { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import useSWR from "swr";
type DataItem = {
key: string;
value: string;
key: string;
value: string;
};
type FormSurat = {
kategoriId: string;
nama: string;
phone: string;
dataPelengkap: DataItem[];
syaratDokumen: DataItem[];
kategoriId: string;
nama: string;
phone: string;
dataPelengkap: DataItem[];
syaratDokumen: DataItem[];
};
export default function FormSurat() {
const [noPengajuan, setNoPengajuan] = useState("")
const [submitLoading, setSubmitLoading] = useState(false)
const navigate = useNavigate();
const { search } = useLocation();
const query = new URLSearchParams(search);
const jenisSurat = query.get("jenis");
const { data, mutate, isLoading } = useSWR("category-pelayanan-list", () =>
apiFetch.api.pelayanan.category.get(),
);
const [jenisSuratFix, setJenisSuratFix] = useState({ name: "", id: "" });
const [dataSurat, setDataSurat] = useState<any>({})
const [formSurat, setFormSurat] = useState<FormSurat>({
const [noPengajuan, setNoPengajuan] = useState("");
const [submitLoading, setSubmitLoading] = useState(false);
const navigate = useNavigate();
const { search } = useLocation();
const query = new URLSearchParams(search);
const jenisSurat = query.get("jenis");
const { data, mutate, isLoading } = useSWR("category-pelayanan-list", () =>
apiFetch.api.pelayanan.category.get(),
);
const [jenisSuratFix, setJenisSuratFix] = useState({ name: "", id: "" });
const [dataSurat, setDataSurat] = useState<any>({});
const [formSurat, setFormSurat] = useState<FormSurat>({
nama: "",
phone: "",
kategoriId: "",
dataPelengkap: [],
syaratDokumen: [],
});
const listCategory = data?.data || [];
function onResetAll() {
setNoPengajuan("");
setSubmitLoading(false);
setFormSurat({
nama: "",
phone: "",
kategoriId: "",
dataPelengkap: [],
syaratDokumen: [],
})
});
}
const listCategory = data?.data || [];
function onResetAll() {
setNoPengajuan("")
setSubmitLoading(false)
setFormSurat({
nama: "",
phone: "",
kategoriId: "",
dataPelengkap: [],
syaratDokumen: [],
})
}
function onGetJenisSurat() {
try {
if (!jenisSurat || jenisSurat == "null") {
setJenisSuratFix({ name: "", id: "" });
} else {
const namaJenis = fromSlug(jenisSurat);
const data = listCategory.find((item: any) => item.name == namaJenis);
if (!data) return;
setJenisSuratFix(data);
}
} catch (error) {
console.error(error);
}
}
async function getDetailJenisSurat() {
try {
const get: any = await apiFetch.api.pelayanan.category.detail.get({
query: {
id: jenisSuratFix.id,
},
})
setDataSurat(get.data)
setFormSurat({
kategoriId: jenisSuratFix.id,
nama: "",
phone: "",
dataPelengkap: (get.data?.dataPelengkap || []).map((item: { key: string }) => ({
key: item.key,
value: "",
})),
syaratDokumen: (get.data?.syaratDokumen || []).map(
(item: { key: string }) => ({
key: item.key,
value: "",
})
),
});
} catch (error) {
console.error(error);
}
}
useShallowEffect(() => {
mutate();
}, []);
useShallowEffect(() => {
if (listCategory.length > 0) {
onGetJenisSurat();
}
}, [jenisSurat, listCategory]);
useShallowEffect(() => {
if (jenisSuratFix.id != "") {
getDetailJenisSurat();
}
}, [jenisSuratFix.id]);
async function onSubmit() {
const isFormKosong = Object.values(formSurat).some((value) => {
if (Array.isArray(value)) {
return (
value.length === 0 ||
value.some(
(item) => typeof item.value === "string" && item.value.trim() === ""
)
)
}
if (typeof value === "string") {
return value.trim() === ""
}
return false
})
if (isFormKosong) {
return notification({
title: "Gagal",
message: "Silahkan lengkapi form surat",
type: "error",
})
}
try {
setSubmitLoading(true)
// 🔥 CLONE state SEKALI
let finalFormSurat = structuredClone(formSurat)
// 2⃣ Upload satu per satu
for (const itemUpload of finalFormSurat.syaratDokumen) {
const updImg = await apiFetch.api.pengaduan.upload.post({
file: itemUpload.value,
folder: "syarat-dokumen",
})
if (updImg.status === 200) {
// 🔥 UPDATE OBJECT LOKAL (BUKAN STATE)
finalFormSurat.syaratDokumen = updateArrayByKey(
finalFormSurat.syaratDokumen,
itemUpload.key,
updImg.data?.filename || ""
)
}
}
// 3⃣ SET STATE SEKALI (optional, untuk UI)
setFormSurat(finalFormSurat)
// 4⃣ SUBMIT KE API
const res = await apiFetch.api.pelayanan.create.post(finalFormSurat)
if (res.status === 200) {
notification({
title: "Berhasil",
message: res.data?.message || "Pengajuan surat berhasil dibuat",
type: "success",
})
} else {
notification({
title: "Gagal",
message: "Pengajuan surat gagal dibuat, silahkan coba beberapa saat lagi",
type: "error",
})
}
} catch (error) {
notification({
title: "Gagal",
message: "Server Error",
type: "error"
})
} finally {
setSubmitLoading(false)
}
}
function updateArrayByKey(list: DataItem[], targetKey: string, value: string): DataItem[] {
return list.map(item =>
item.key === targetKey
? { ...item, value }
: item
)
}
function validationForm({ key, value }: { key: 'nama' | 'phone' | 'dataPelengkap' | 'syaratDokumen', value: any }) {
if (key == "dataPelengkap" || key == "syaratDokumen") {
setFormSurat(prev => ({
...prev,
[key]: updateArrayByKey(
prev[key],
value.key,
value.value
)
}))
function onGetJenisSurat() {
try {
if (!jenisSurat || jenisSurat == "null") {
setJenisSuratFix({ name: "", id: "" });
} else {
setFormSurat({
...formSurat,
[key]: value,
})
const namaJenis = fromSlug(jenisSurat);
const data = listCategory.find((item: any) => item.name == namaJenis);
if (!data) return;
setJenisSuratFix(data);
}
}
} catch (error) {
console.error(error);
}
}
return (
<Container size="md" w={"100%"}>
<FullScreenLoading visible={submitLoading} />
{
noPengajuan != "" &&
<SuccessPengajuan noPengajuan={noPengajuan} onClose={() => { onResetAll(); navigate("/darmasaba/surat") }} />
}
<Box>
<Stack gap="lg">
<Group justify="space-between" align="center">
<Group align="center">
<IconBuildingCommunity size={28} />
<div>
<Text fw={800} size="xl">
Layanan Pengajuan Surat Administrasi
</Text>
<Text size="sm" c="dimmed">
Formulir resmi untuk mengajukan berbagai jenis surat administrasi desa/kelurahan secara online.
</Text>
</div>
</Group>
<Group>
<Badge radius="sm">Form Length: 3 Sections</Badge>
</Group>
</Group>
<Stack gap="lg">
{/* Header Section */}
<FormSection
title="Pemohon"
icon={<IconUser size={16} />}
description="Informasi identitas pemohon"
>
<Grid>
<Grid.Col span={6}>
<TextInput
label={
<FieldLabel
label="Nama"
hint="Nama pemohon"
/>
}
placeholder="Budi Setiawan"
value={formSurat.nama}
onChange={(e) => validationForm({ key: "nama", value: e.target.value })}
/>
</Grid.Col>
async function getDetailJenisSurat() {
try {
const get: any = await apiFetch.api.pelayanan.category.detail.get({
query: {
id: jenisSuratFix.id,
},
});
<Grid.Col span={6}>
<TextInput
label={
<FieldLabel
label="Nomor Telephone"
hint="Nomor telephone yang dapat dihubungi / terhubung dengan whatsapp"
/>
}
placeholder="08123456789"
value={formSurat.phone}
onChange={(e) => validationForm({ key: "phone", value: e.target.value })}
/>
</Grid.Col>
setDataSurat(get.data);
setFormSurat({
kategoriId: jenisSuratFix.id,
nama: "",
phone: "",
dataPelengkap: (get.data?.dataPelengkap || []).map(
(item: { key: string }) => ({
key: item.key,
value: "",
}),
),
syaratDokumen: (get.data?.syaratDokumen || []).map(
(item: { key: string }) => ({
key: item.key,
value: "",
}),
),
});
} catch (error) {
console.error(error);
}
}
<Grid.Col span={12}>
<Select
label={<FieldLabel label="Jenis Surat" hint="Jenis surat yang ingin diajukan" />}
placeholder="Pilih jenis surat"
data={listCategory.map((item: any) => ({
value: item.name,
label: item.name,
}))}
value={jenisSuratFix.name}
onChange={(value) => {
const slug = toSlug(String(value))
navigate("/darmasaba/surat?jenis=" + slug)
}}
/>
</Grid.Col>
</Grid>
</FormSection>
useShallowEffect(() => {
mutate();
}, []);
{
jenisSuratFix.id != "" && dataSurat && dataSurat.dataPelengkap &&
<>
<FormSection
title="Data Pelengkap"
description="Data pelengkap yang diperlukan"
>
<Grid>
{
dataSurat.dataPelengkap.map((item: any, index: number) => (
<Grid.Col span={6} key={index}>
<TextInput
label={
<FieldLabel
label={item.name}
hint={item.desc}
/>
}
placeholder={item.name}
onChange={(e) => validationForm({ key: "dataPelengkap", value: { key: item.key, value: e.target.value } })}
value={formSurat.dataPelengkap.find((n: any) => n.key == item.key)?.value}
/>
</Grid.Col>
))
}
</Grid>
</FormSection>
useShallowEffect(() => {
if (listCategory.length > 0) {
onGetJenisSurat();
}
}, [jenisSurat, listCategory]);
<FormSection
title="Syarat Dokumen"
description="Syarat dokumen yang diperlukan"
>
<Grid>
{
dataSurat.syaratDokumen.map((item: any, index: number) => (
<Grid.Col span={6} key={index}>
<FileInputWrapper
label={item.desc}
placeholder={"Upload file "}
accept="image/*,application/pdf"
onChange={(file) => validationForm({ key: "syaratDokumen", value: { key: item.key, value: file } })}
name={item.name}
/>
</Grid.Col>
))
}
</Grid>
</FormSection>
useShallowEffect(() => {
if (jenisSuratFix.id != "") {
getDetailJenisSurat();
}
}, [jenisSuratFix.id]);
{/* Actions */}
<Group justify="right" mt="md">
<Button variant="default" onClick={() => { }}>
Reset
</Button>
<Button onClick={onSubmit}>Kirim</Button>
</Group>
</>
async function onSubmit() {
const isFormKosong = Object.values(formSurat).some((value) => {
if (Array.isArray(value)) {
return (
value.length === 0 ||
value.some(
(item) =>
typeof item.value === "string" && item.value.trim() === "",
)
);
}
}
</Stack>
</Stack>
</Box>
</Container>
);
if (typeof value === "string") {
return value.trim() === "";
}
return false;
});
if (isFormKosong) {
return notification({
title: "Gagal",
message: "Silahkan lengkapi form surat",
type: "error",
});
}
try {
setSubmitLoading(true);
// 🔥 CLONE state SEKALI
let finalFormSurat = structuredClone(formSurat);
// 2⃣ Upload satu per satu
for (const itemUpload of finalFormSurat.syaratDokumen) {
const updImg = await apiFetch.api.pengaduan.upload.post({
file: itemUpload.value,
folder: "syarat-dokumen",
});
if (updImg.status === 200) {
// 🔥 UPDATE OBJECT LOKAL (BUKAN STATE)
finalFormSurat.syaratDokumen = updateArrayByKey(
finalFormSurat.syaratDokumen,
itemUpload.key,
updImg.data?.filename || "",
);
}
}
// 3⃣ SET STATE SEKALI (optional, untuk UI)
setFormSurat(finalFormSurat);
// 4⃣ SUBMIT KE API
const res = await apiFetch.api.pelayanan.create.post(finalFormSurat);
if (res.status === 200) {
notification({
title: "Berhasil",
message: res.data?.message || "Pengajuan surat berhasil dibuat",
type: "success",
});
} else {
notification({
title: "Gagal",
message:
"Pengajuan surat gagal dibuat, silahkan coba beberapa saat lagi",
type: "error",
});
}
} catch (error) {
notification({
title: "Gagal",
message: "Server Error",
type: "error",
});
} finally {
setSubmitLoading(false);
}
}
function updateArrayByKey(
list: DataItem[],
targetKey: string,
value: string,
): DataItem[] {
return list.map((item) =>
item.key === targetKey ? { ...item, value } : item,
);
}
function validationForm({
key,
value,
}: {
key: "nama" | "phone" | "dataPelengkap" | "syaratDokumen";
value: any;
}) {
if (key == "dataPelengkap" || key == "syaratDokumen") {
setFormSurat((prev) => ({
...prev,
[key]: updateArrayByKey(prev[key], value.key, value.value),
}));
} else {
setFormSurat({
...formSurat,
[key]: value,
});
}
}
return (
<Container size="md" w={"100%"}>
<FullScreenLoading visible={submitLoading} />
{noPengajuan != "" && (
<SuccessPengajuan
noPengajuan={noPengajuan}
onClose={() => {
onResetAll();
navigate("/darmasaba/surat");
}}
/>
)}
<Box>
<Stack gap="lg">
<Group justify="space-between" align="center">
<Group align="center">
<IconBuildingCommunity size={28} />
<div>
<Text fw={800} size="xl">
Layanan Pengajuan Surat Administrasi
</Text>
<Text size="sm" c="dimmed">
Formulir resmi untuk mengajukan berbagai jenis surat
administrasi desa/kelurahan secara online.
</Text>
</div>
</Group>
<Group>
<Badge radius="sm">Form Length: 3 Sections</Badge>
</Group>
</Group>
<Stack gap="lg">
{/* Header Section */}
<FormSection
title="Pemohon"
icon={<IconUser size={16} />}
description="Informasi identitas pemohon"
>
<Grid>
<Grid.Col span={6}>
<TextInput
label={<FieldLabel label="Nama" hint="Nama pemohon" />}
placeholder="Budi Setiawan"
value={formSurat.nama}
onChange={(e) =>
validationForm({ key: "nama", value: e.target.value })
}
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label={
<FieldLabel
label="Nomor Telephone"
hint="Nomor telephone yang dapat dihubungi / terhubung dengan whatsapp"
/>
}
placeholder="08123456789"
value={formSurat.phone}
onChange={(e) =>
validationForm({ key: "phone", value: e.target.value })
}
/>
</Grid.Col>
<Grid.Col span={12}>
<Select
label={
<FieldLabel
label="Jenis Surat"
hint="Jenis surat yang ingin diajukan"
/>
}
placeholder="Pilih jenis surat"
data={listCategory.map((item: any) => ({
value: item.name,
label: item.name,
}))}
value={jenisSuratFix.name}
onChange={(value) => {
const slug = toSlug(String(value));
navigate("/darmasaba/surat?jenis=" + slug);
}}
/>
</Grid.Col>
</Grid>
</FormSection>
{jenisSuratFix.id != "" && dataSurat && dataSurat.dataPelengkap && (
<>
<FormSection
title="Data Pelengkap"
description="Data pelengkap yang diperlukan"
>
<Grid>
{dataSurat.dataPelengkap.map((item: any, index: number) => (
<Grid.Col span={6} key={index}>
<TextInput
label={
<FieldLabel label={item.name} hint={item.desc} />
}
placeholder={item.name}
onChange={(e) =>
validationForm({
key: "dataPelengkap",
value: { key: item.key, value: e.target.value },
})
}
value={
formSurat.dataPelengkap.find(
(n: any) => n.key == item.key,
)?.value
}
/>
</Grid.Col>
))}
</Grid>
</FormSection>
<FormSection
title="Syarat Dokumen"
description="Syarat dokumen yang diperlukan"
>
<Grid>
{dataSurat.syaratDokumen.map((item: any, index: number) => (
<Grid.Col span={6} key={index}>
<FileInputWrapper
label={item.desc}
placeholder={"Upload file "}
accept="image/*,application/pdf"
onChange={(file) =>
validationForm({
key: "syaratDokumen",
value: { key: item.key, value: file },
})
}
name={item.name}
/>
</Grid.Col>
))}
</Grid>
</FormSection>
{/* Actions */}
<Group justify="right" mt="md">
<Button variant="default" onClick={() => {}}>
Reset
</Button>
<Button onClick={onSubmit}>Kirim</Button>
</Group>
</>
)}
</Stack>
</Stack>
</Box>
</Container>
);
}
function FieldLabel({ label, hint }: { label: string; hint?: string }) {
return (
<Group justify="apart" gap="xs" align="center">
<Text fw={600}>{label}</Text>
{hint && (
<Tooltip label={hint} withArrow>
<ActionIcon size={24} variant="subtle">
<IconInfoCircle size={16} />
</ActionIcon>
</Tooltip>
)}
</Group>
);
return (
<Group justify="apart" gap="xs" align="center">
<Text fw={600}>{label}</Text>
{hint && (
<Tooltip label={hint} withArrow>
<ActionIcon size={24} variant="subtle">
<IconInfoCircle size={16} />
</ActionIcon>
</Tooltip>
)}
</Group>
);
}
function FormSection({
title,
icon,
children,
description,
title,
icon,
children,
description,
}: {
title: string;
icon?: React.ReactNode;
children: React.ReactNode;
description?: string;
title: string;
icon?: React.ReactNode;
children: React.ReactNode;
description?: string;
}) {
return (
<Card radius="md" shadow="sm" withBorder>
<Group justify="apart" align="center" mb="xs">
<Group align="center" gap="xs">
{icon}
<Text fw={700}>{title}</Text>
</Group>
{description && <Badge variant="light">{description}</Badge>}
</Group>
return (
<Card radius="md" shadow="sm" withBorder>
<Group justify="apart" align="center" mb="xs">
<Group align="center" gap="xs">
{icon}
<Text fw={700}>{title}</Text>
</Group>
{description && <Badge variant="light">{description}</Badge>}
</Group>
<Divider mb="sm" />
<Stack gap="sm">{children}</Stack>
</Card>
);
<Divider mb="sm" />
<Stack gap="sm">{children}</Stack>
</Card>
);
}
function FileInputWrapper({
label,
placeholder,
accept,
onChange,
preview,
name,
description,
label,
placeholder,
accept,
onChange,
preview,
name,
description,
}: {
label: string;
placeholder?: string;
accept?: string;
onChange: (file: File | null) => void;
preview?: string | null;
name: string;
description?: string;
label: string;
placeholder?: string;
accept?: string;
onChange: (file: File | null) => void;
preview?: string | null;
name: string;
description?: string;
}) {
return (
<Stack gap="xs">
<Flex direction={"column"}>
<Group justify="apart" align="center">
<Text fw={500}>{label}</Text>
</Group>
{description && (
<Text size="sm" c="dimmed" mt={4} style={{ lineHeight: 1.2 }}>
{description}
</Text>
)}
</Flex>
return (
<Stack gap="xs">
<Flex direction={"column"}>
<Group justify="apart" align="center">
<Text fw={500}>{label}</Text>
</Group>
{description && (
<Text size="sm" c="dimmed" mt={4} style={{ lineHeight: 1.2 }}>
{description}
</Text>
)}
</Flex>
<FileInput
accept={accept}
placeholder={placeholder}
onChange={(f) => onChange(f)}
leftSection={<IconUpload />}
aria-label={label}
name={name}
/>
<FileInput
accept={accept}
placeholder={placeholder}
onChange={(f) => onChange(f)}
leftSection={<IconUpload />}
aria-label={label}
name={name}
/>
{preview ? (
<div>
<Text size="xs" color="dimmed">
Preview:
</Text>
{/* If preview is an image it will show; pdf preview might not render as image */}
{/* Use <object> or <img> depending on file type — keep simple here */}
<div style={{ marginTop: 6 }}>
<img
src={preview}
alt={`${label} preview`}
style={{ maxWidth: "200px", borderRadius: 4 }}
/>
</div>
</div>
) : null}
</Stack>
);
{preview ? (
<div>
<Text size="xs" color="dimmed">
Preview:
</Text>
{/* If preview is an image it will show; pdf preview might not render as image */}
{/* Use <object> or <img> depending on file type — keep simple here */}
<div style={{ marginTop: 6 }}>
<img
src={preview}
alt={`${label} preview`}
style={{ maxWidth: "200px", borderRadius: 4 }}
/>
</div>
</div>
) : null}
</Stack>
);
}

View File

@@ -0,0 +1,474 @@
import ModalFile from "@/components/ModalFile";
import notification from "@/components/notificationGlobal";
import apiFetch from "@/lib/apiFetch";
import {
ActionIcon,
Alert,
Anchor,
Badge,
Box,
Button,
Card,
Container,
Divider,
FileInput,
Flex,
Grid,
Group,
Stack,
Text,
TextInput,
Tooltip
} from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks";
import {
IconBuildingCommunity,
IconInfoCircle,
IconUpload
} from "@tabler/icons-react";
import _ from "lodash";
import React, { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
type DataItem = {
key: string;
value: string;
};
type UpdateDataItem = {
id: string;
key: string;
value: any;
};
type FormSurat = {
kategoriId: string;
nama: string;
phone: string;
dataPelengkap: DataItem[];
syaratDokumen: DataItem[];
};
type FormUpdateSurat = {
dataPelengkap: UpdateDataItem[];
syaratDokumen: UpdateDataItem[];
};
type DataPengajuan = {
id: string;
noPengajuan: string;
category: string;
status: "antrian" | "diproses" | "selesai" | "ditolak";
createdAt: Date;
updatedAt: Date;
idSurat: string | undefined;
}
export default function UpdateDataSurat() {
const navigate = useNavigate();
const { search } = useLocation();
const query = new URLSearchParams(search);
const noPengajuan = query.get("pengajuan");
return (
<Container size="md" w={"100%"}>
<Box>
<Stack gap="lg">
<Group justify="space-between" align="center" mt={"lg"}>
<Group align="center">
<IconBuildingCommunity size={28} />
<div>
<Text fw={800} size="xl">
Update Data Pengajuan Surat Administrasi
</Text>
<Text size="sm" c="dimmed">
Formulir ini digunakan untuk memperbarui data pengajuan surat administrasi yang telah diajukan sebelumnya.
</Text>
</div>
</Group>
</Group>
<Stack gap="lg" mb="lg">
{
!noPengajuan ? (
<SearchData />
)
:
<DataUpdate noPengajuan={noPengajuan} />
}
</Stack>
</Stack>
</Box>
</Container>
);
}
function FieldLabel({ label, hint }: { label: string; hint?: string }) {
return (
<Group justify="apart" gap="xs" align="center">
<Text fw={600}>{label}</Text>
{hint && (
<Tooltip label={hint} withArrow>
<ActionIcon size={24} variant="subtle">
<IconInfoCircle size={16} />
</ActionIcon>
</Tooltip>
)}
</Group>
);
}
function FormSection({
title,
icon,
children,
description,
info,
}: {
title?: string;
icon?: React.ReactNode;
children: React.ReactNode;
description?: string;
info?: string;
}) {
return (
<Card radius="md" shadow="sm" withBorder>
<Box mb="xs">
<Group justify="apart" align="center">
<Group align="center" gap="xs">
{icon}
{title && <Text fw={700}>{title}</Text>}
</Group>
{description && <Badge variant="light">{description}</Badge>}
</Group>
{info && <Text size="sm" c="dimmed">{info}</Text>}
</Box>
{
title && <Divider mb="sm" />
}
<Stack gap="sm">{children}</Stack>
</Card>
);
}
function FileInputWrapper({
label,
placeholder,
accept,
onChange,
preview,
name,
description,
linkView,
disabled
}: {
label: string;
placeholder?: string;
accept?: string;
linkView?: string;
onChange: (file: File | null) => void;
preview?: string | null;
name: string;
description?: string;
disabled?: boolean;
}) {
const [viewImg, setViewImg] = useState("");
const [openedPreviewFile, setOpenedPreviewFile] = useState(false);
useShallowEffect(() => {
if (viewImg) {
setOpenedPreviewFile(true);
}
}, [viewImg]);
return (
<>
<ModalFile
open={openedPreviewFile && !_.isEmpty(viewImg)}
onClose={() => {
setOpenedPreviewFile(false);
}}
folder="syarat-dokumen"
fileName={viewImg}
/>
<Stack gap="xs">
<Flex direction={"column"}>
<Group justify="apart" align="center">
<Text fw={500}>{label}</Text>
</Group>
{description && (
<Text size="sm" c="dimmed" mt={4} style={{ lineHeight: 1.2 }}>
{description}
</Text>
)}
{
linkView && (
<Anchor onClick={() => setViewImg(linkView)} size="sm">
Lihat dokumen sebelumnya
</Anchor>
)
}
</Flex>
<FileInput
accept={accept}
placeholder={placeholder}
onChange={(f) => onChange(f)}
leftSection={<IconUpload />}
aria-label={label}
name={name}
disabled={disabled}
/>
{preview ? (
<div>
<Text size="xs" color="dimmed">
Preview:
</Text>
<div style={{ marginTop: 6 }}>
<img
src={preview}
alt={`${label} preview`}
style={{ maxWidth: "200px", borderRadius: 4 }}
/>
</div>
</div>
) : null}
</Stack>
</>
);
}
function SearchData() {
const [submitLoading, setSubmitLoading] = useState(false);
const [searchPengajuan, setSearchPengajuan] = useState("");
const [searchPengajuanPhone, setSearchPengajuanPhone] = useState("");
const navigate = useNavigate();
async function handleSearch() {
try {
setSubmitLoading(true);
if (searchPengajuan == "" || searchPengajuanPhone == "") {
notification({
title: "Peringatan",
message: "Silakan isi nomor pengajuan atau nomor telephone",
type: "warning"
});
return;
}
const response = await apiFetch.api.pelayanan["get-no-pengajuan"].post({
phone: searchPengajuanPhone,
noPengajuan: searchPengajuan
});
if (response.status === 200) {
if (response.data?.success) {
navigate(`/darmasaba/update-data-surat?pengajuan=${response.data.nomer}`);
} else {
notification({
title: "Peringatan",
message: response.data?.message || "Data pengajuan tidak valid",
type: "warning"
});
}
} else {
notification({
title: "Error",
message: "Pengajuan tidak ditemukan atau gagal memuat data",
type: "error"
});
}
} catch (error) {
console.error("Error searching:", error);
notification({
title: "Error",
message: "Gagal mencari data pengajuan",
type: "error"
});
} finally {
setSubmitLoading(false);
}
}
return (
<FormSection
title="Cari Pengajuan Surat"
info="Masukkan nomor pengajuan dan nomor telepon yang digunakan saat pengajuan surat."
>
<Grid>
<Grid.Col span={6}>
<TextInput
label={<FieldLabel label="Nomor Pengajuan" hint="Nomor pengajuan surat" />}
placeholder="PS-2025-000123"
onChange={(e) => { setSearchPengajuan(e.target.value) }}
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label={
<FieldLabel
label="Nomor Telephone"
hint="Nomor telephone yang dapat dihubungi / terhubung dengan whatsapp"
/>
}
placeholder="08123456789"
type="number"
onChange={(e) => { setSearchPengajuanPhone(e.target.value) }}
/>
</Grid.Col>
<Grid.Col span={12}>
<Button fullWidth variant="light" color="blue" onClick={() => { handleSearch() }} loading={submitLoading}>
Cari Pengajuan
</Button>
</Grid.Col>
</Grid>
</FormSection>
)
}
function DataUpdate({ noPengajuan }: { noPengajuan: string }) {
const [dataPelengkap, setDataPelengkap] = useState<DataItem[]>([])
const [dataSyaratDokumen, setDataSyaratDokumen] = useState<DataItem[]>([])
const [dataPengajuan, setDataPengajuan] = useState<DataPengajuan | {}>({})
const [status, setStatus] = useState("")
const [formSurat, setFormSurat] = useState<FormUpdateSurat>({
dataPelengkap: [],
syaratDokumen: [],
});
async function fetchData() {
try {
const res = await apiFetch.api.pelayanan["detail-data"].post({ nomerPengajuan: noPengajuan });
if (res.data && res.data.success === true) {
setDataPelengkap(res.data.dataPelengkap || []);
setDataSyaratDokumen(res.data.syaratDokumen || []);
setDataPengajuan(res.data.pengajuan || {});
setStatus(res.data.pengajuan && 'status' in res.data.pengajuan ? res.data.pengajuan.status : "");
} else {
notification({
title: "Error",
message: res.data?.message || "Gagal memuat data",
type: "error",
});
setDataPelengkap([]);
setDataSyaratDokumen([]);
setDataPengajuan({});
}
} catch (error) {
console.error('Error fetching data:', error);
}
}
useShallowEffect(() => {
fetchData();
}, []);
function upsertById<T extends { id: string }>(
array: T[],
item: T
): T[] {
const index = array.findIndex((v) => v.id === item.id)
// insert
if (index === -1) {
return [...array, item]
}
// ✏️ update
return array.map((v, i) => (i === index ? { ...v, ...item } : v))
}
function validationForm({
kategori,
value,
}: {
kategori: "dataPelengkap" | "syaratDokumen";
value: UpdateDataItem;
}) {
setFormSurat((prev) => ({
...prev,
[kategori]: upsertById(prev[kategori], {
id: value.id,
key: value.key,
value: value.value
})
}));
}
return (
<>
{
(status != "ditolak" && status != "antrian")
&& <Alert variant="light" color="yellow" radius="lg" title={`Data pengajuan surat ini tidak dapat diupdate karena berstatus ${status}.`} icon={<span style={{ fontSize: '1.2rem' }}></span>} />
}
<FormSection
title="Data Pelengkap"
description="Data pelengkap yang diperlukan"
>
<Grid>
{dataPelengkap.map((item: any, index: number) => (
<Grid.Col span={6} key={index}>
<TextInput
label={
<FieldLabel label={item.name} hint={item.desc} />
}
placeholder={item.name}
onChange={(e) =>
validationForm({
kategori: "dataPelengkap",
value: { id: item.id, key: item.key, value: e.target.value },
})
}
value={formSurat.dataPelengkap.find((n) => n.id === item.id)?.value ?? dataPelengkap.find((n: any) => n.key == item.key,)?.value}
disabled={status != "ditolak" && status != "antrian"}
/>
</Grid.Col>
))}
</Grid>
</FormSection>
<FormSection
title="Syarat Dokumen"
description="Syarat dokumen yang diperlukan"
>
<Grid>
{dataSyaratDokumen.map((item: any, index: number) => (
<Grid.Col span={6} key={index}>
<FileInputWrapper
label={item.desc}
placeholder={"Upload file terbaru untuk mengupdate"}
accept="image/*,application/pdf"
linkView={item.value}
onChange={(file) =>
validationForm({
kategori: "syaratDokumen",
value: { id: item.id, key: item.key, value: file },
})
}
name={item.name}
disabled={status != "ditolak" && status != "antrian"}
/>
</Grid.Col>
))}
</Grid>
</FormSection>
<Group justify="right" mt="md">
<Button onClick={() => console.log('Submit clicked')}>Kirim</Button>
</Group>
</>
)
}

View File

@@ -215,7 +215,7 @@ const PelayananRoute = new Elysia({
select: {
name: true,
syaratDokumen: true,
dataPelengkap:true
dataPelengkap: true
}
},
Warga: {
@@ -575,7 +575,7 @@ const PelayananRoute = new Elysia({
CategoryPelayanan: {
select: {
name: true,
dataText: true,
dataPelengkap: true,
syaratDokumen: true,
}
},
@@ -595,7 +595,15 @@ const PelayananRoute = new Elysia({
})
if (!data) {
return { success: false, message: "Data tidak ditemukan" }
return {
success: false,
message: "Data tidak ditemukan",
pengajuan: {},
history: [],
warga: {},
syaratDokumen: [],
dataPelengkap: [],
}
}
const dataSurat = await prisma.suratPelayanan.findFirst({
@@ -621,7 +629,7 @@ const PelayananRoute = new Elysia({
}
})
const dataText = await prisma.dataTextPelayanan.findMany({
const dataPelengkap = await prisma.dataTextPelayanan.findMany({
where: {
idPengajuanLayanan: data?.id,
isActive: true
@@ -635,23 +643,37 @@ const PelayananRoute = new Elysia({
const syaratDokumen = (data?.CategoryPelayanan?.syaratDokumen ?? []) as {
name: string;
desc: string;
key: string;
}[];
const dataSyaratFix = dataSyarat.map((item) => {
// const desc = syaratDokumen.find((v) => v.name == item.jenis)?.desc
const desc = syaratDokumen.find((v) => v.key == item.jenis)?.desc
const name = syaratDokumen.find((v) => v.key == item.jenis)?.name
return {
id: item.id,
jenis: item.jenis,
key: item.jenis,
value: item.value,
name: name ?? '',
desc: desc ?? ''
}
})
const dataTextFix = dataText.map((item) => {
// const desc = data?.CategoryPelayanan?.dataText.find((v) => v == item.jenis)
const dataPelengkapList = (data?.CategoryPelayanan?.dataPelengkap ?? []) as {
name: string;
desc: string;
key: string;
}[];
const dataTextFix = dataPelengkap.map((item) => {
const ini = dataPelengkapList.find((v) => v.key == item.jenis)
const desc = ini?.desc
const name = ini?.name
return {
id: item.id,
jenis: item.jenis,
key: item.jenis,
value: item.value,
desc: desc ?? '',
name: name ?? ''
}
})
@@ -702,11 +724,13 @@ const PelayananRoute = new Elysia({
}
const datafix = {
success: true,
message: 'sukses',
pengajuan: dataPengajuan,
history: dataHistoryFix,
warga: warga,
syaratDokumen: dataSyaratFix,
dataText: dataTextFix,
dataPelengkap: dataTextFix,
}
@@ -786,12 +810,12 @@ const PelayananRoute = new Elysia({
}
})
.post("/update", async ({ body }) => {
const { nomerPengajuan, syaratDokumen, dataText } = body
const { id, syaratDokumen, dataPelengkap } = body
let dataUpdate = []
const pengajuan = await prisma.pelayananAjuan.findFirst({
const pengajuan = await prisma.pelayananAjuan.findUnique({
where: {
noPengajuan: nomerPengajuan,
id
}
})
@@ -804,29 +828,16 @@ const PelayananRoute = new Elysia({
return { success: false, message: 'pengajuan surat tidak dapat diupdate karena status ' + pengajuan.status }
}
if (dataText && dataText.length > 0) {
for (const item of dataText) {
dataUpdate.push(item.jenis)
const hasil = await prisma.dataTextPelayanan.findFirst({
where: {
idPengajuanLayanan: pengajuan.id,
jenis: item.jenis,
}
})
if (dataPelengkap && dataPelengkap.length > 0) {
for (const item of dataPelengkap) {
dataUpdate.push(item.key)
const upd = await prisma.dataTextPelayanan.upsert({
const upd = await prisma.dataTextPelayanan.update({
where: {
id: hasil?.id
id: item.id
},
update: {
data: {
value: item.value,
},
create: {
value: item.value,
jenis: item.jenis,
idPengajuanLayanan: pengajuan.id,
idCategory: pengajuan.idCategory,
}
})
@@ -849,34 +860,15 @@ const PelayananRoute = new Elysia({
if (syaratDokumen && syaratDokumen.length > 0) {
for (const item of syaratDokumen) {
const pilih = syarat?.find((cat) => cat.desc.toLowerCase() == item.jenis.toLowerCase() || cat.name.toLowerCase() == item.jenis.toLowerCase())?.name;
dataUpdate.push(pilih)
const hasil = await prisma.syaratDokumenPelayanan.findFirst({
dataUpdate.push(item.key)
const upd = await prisma.syaratDokumenPelayanan.update({
where: {
idPengajuanLayanan: pengajuan.id,
jenis: pilih,
id: item.id
},
data: {
value: item.value,
}
})
if (hasil && hasil.id) {
const upd = await prisma.syaratDokumenPelayanan.upsert({
where: {
id: hasil.id
},
update: {
value: item.value,
},
create: {
value: item.value,
jenis: hasil.jenis,
idPengajuanLayanan: pengajuan.id,
idCategory: pengajuan.idCategory,
}
})
} else {
return { success: false, message: 'dokumen tidak dapat diupload' }
}
}
}
@@ -906,16 +898,20 @@ const PelayananRoute = new Elysia({
}, {
body: t.Object({
nomerPengajuan: t.String({
error: "nomer pengajuan harus diisi",
description: "Nomer pengajuan yang ingin diupdate"
id: t.String({
error: "id harus diisi",
description: "ID yang ingin diupdate"
}),
dataText: t.Optional(t.Array(
dataPelengkap: t.Optional(t.Array(
t.Object({
jenis: t.String({
description: "Jenis field yang dibutuhkan oleh kategori pelayanan. Biasanya dinamis.",
id: t.String({
description: "ID Data Pelengkap.",
error: "id harus diisi"
}),
key: t.String({
description: "Key field yang dibutuhkan oleh kategori pelayanan. Biasanya dinamis.",
examples: ["nama", "jenis kelamin", "tempat tanggal lahir", "negara", "agama", "status perkawinan", "alamat", "pekerjaan", "jenis usaha", "alamat usaha"],
error: "jenis harus diisi"
error: "key harus diisi"
}),
value: t.String({
description: "Isi atau nilai dari jenis field terkait.",
@@ -927,26 +923,30 @@ const PelayananRoute = new Elysia({
description: "Kumpulan data text dinamis sesuai kategori layanan.",
examples: [
[
{ jenis: "nama", value: "Budi Santoso" },
{ jenis: "jenis kelamin", value: "Laki-laki" },
{ jenis: "tempat tanggal lahir", value: "Denpasar, 28 Februari 1990" },
{ jenis: "negara", value: "Indonesia" },
{ jenis: "agama", value: "Islam" },
{ jenis: "status perkawinan", value: "Belum menikah" },
{ jenis: "alamat", value: "Jl. Mawar No. 10" },
{ jenis: "pekerjaan", value: "Karyawan Swasta" },
{ jenis: "jenis usaha", value: "usaha makanan" },
{ jenis: "alamat usaha", value: "Jl. Melati No. 21" },
{ id: "1", key: "nama", value: "Budi Santoso" },
{ id: "2", key: "jenis kelamin", value: "Laki-laki" },
{ id: "3", key: "tempat tanggal lahir", value: "Denpasar, 28 Februari 1990" },
{ id: "4", key: "negara", value: "Indonesia" },
{ id: "5", key: "agama", value: "Islam" },
{ id: "6", key: "status perkawinan", value: "Belum menikah" },
{ id: "7", key: "alamat", value: "Jl. Mawar No. 10" },
{ id: "8", key: "pekerjaan", value: "Karyawan Swasta" },
{ id: "9", key: "jenis usaha", value: "usaha makanan" },
{ id: "10", key: "alamat usaha", value: "Jl. Melati No. 21" },
]
],
}
)),
syaratDokumen: t.Optional(t.Array(
t.Object({
jenis: t.String({
description: "Jenis dokumen persyaratan yang diminta oleh kategori layanan.",
id: t.String({
description: "ID syarat dokumen",
error: "id harus diisi"
}),
key: t.String({
description: "Key dokumen persyaratan yang diminta oleh kategori layanan.",
examples: ["ktp", "kk", "surat_pengantar_rt"],
error: "jenis harus diisi"
error: "key harus diisi"
}),
value: t.String({
description: "Nama file atau identifier file dokumen yang diupload.",
@@ -958,9 +958,9 @@ const PelayananRoute = new Elysia({
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" }
{ id: "1", key: "pengantar kelian", value: "pengantar_kelurahan_budi.png" },
{ id: "2", key: "ktp/kk", value: "kk_budi.png" },
{ id: "3", key: "foto lokasi", value: "foto_lokasi_budi.png" }
]
],
}
@@ -969,7 +969,6 @@ const PelayananRoute = new Elysia({
detail: {
summary: "Update Data Pengajuan Pelayanan Surat",
description: `tool untuk update data pengajuan pelayanan surat`,
tags: ["mcp"]
}
})
.get("/list", async ({ query }) => {
@@ -1116,5 +1115,47 @@ const PelayananRoute = new Elysia({
description: `tool untuk mendapatkan jumlah pengajuan pelayanan surat warga`,
}
})
.post("/get-no-pengajuan", async ({ body }) => {
const { phone, noPengajuan } = body;
if (!isValidPhone(phone)) {
return { success: false, message: 'nomor telepon tidak valid, harap masukkan nomor yang benar' }
}
const nomorHP = normalizePhoneNumber({ phone: phone })
const data = await prisma.pelayananAjuan.findMany({
where: {
noPengajuan: noPengajuan,
Warga: {
phone: nomorHP
}
},
select: {
id: true,
noPengajuan: true,
status: true,
createdAt: true,
}
});
if (data.length == 0) {
return { success: false, message: 'Data pengajuan tidak ditemukan' };
}
return {
success: true,
nomer: noPengajuan
};
}, {
body: t.Object({
phone: t.String({ minLength: 1, error: "Nomor telepon harus diisi" }),
noPengajuan: t.String({ minLength: 1, error: "Nomor pengajuan harus diisi" }),
}),
detail: {
summary: "Cek Nomor Pengajuan Surat",
description: `tool untuk memeriksa apakah nomor pengajuan surat valid dan terkait dengan nomor telepon warga. Jika valid, mengembalikan nomor pengajuan.`,
}
})
export default PelayananRoute