upd: update data pelayanan surat

Deskripsi
- form pencarian
- detail data pengajuan
- api
- belom selesai submit

NO Issues
This commit is contained in:
2025-12-19 17:27:39 +08:00
parent fda2b0977a
commit ff0413be5a
7 changed files with 1121 additions and 595 deletions

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>
);
}