704 lines
21 KiB
TypeScript
704 lines
21 KiB
TypeScript
import FullScreenLoading from "@/components/FullScreenLoading";
|
||
import notification from "@/components/notificationGlobal";
|
||
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,
|
||
Modal,
|
||
Select,
|
||
Stack,
|
||
Text,
|
||
TextInput,
|
||
Tooltip,
|
||
} from "@mantine/core";
|
||
import { DateInput } from "@mantine/dates";
|
||
import { useDisclosure, useShallowEffect } from "@mantine/hooks";
|
||
import {
|
||
IconBuildingCommunity,
|
||
IconCategory,
|
||
IconFiles,
|
||
IconInfoCircle,
|
||
IconNotes,
|
||
IconPhone,
|
||
IconUpload,
|
||
} from "@tabler/icons-react";
|
||
import dayjs from "dayjs";
|
||
import "dayjs/locale/id";
|
||
import React, { useState } from "react";
|
||
import { useLocation, useNavigate } from "react-router-dom";
|
||
import useSWR from "swr";
|
||
|
||
type DataItem = {
|
||
key: string;
|
||
value: string;
|
||
required: boolean;
|
||
};
|
||
|
||
type FormSurat = {
|
||
kategoriId: string;
|
||
nama: string;
|
||
phone: string;
|
||
dataPelengkap: DataItem[];
|
||
syaratDokumen: DataItem[];
|
||
};
|
||
|
||
type ErrorState = Record<string, string | null>;
|
||
|
||
export default function FormSurat() {
|
||
const [errors, setErrors] = useState<ErrorState>({});
|
||
const [opened, { open, close }] = useDisclosure(false);
|
||
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: [],
|
||
});
|
||
}
|
||
|
||
function onGetJenisSurat() {
|
||
try {
|
||
if (!jenisSurat || jenisSurat == "null") {
|
||
setJenisSuratFix({ name: "", id: "" });
|
||
} else {
|
||
const namaJenis = fromSlug(jenisSurat);
|
||
const data = listCategory.find((item: any) => item.name.toUpperCase() == namaJenis.toUpperCase());
|
||
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, required: boolean }) => ({
|
||
key: item.key,
|
||
value: "",
|
||
required: item.required
|
||
}),
|
||
),
|
||
syaratDokumen: (get.data?.syaratDokumen || []).map(
|
||
(item: { key: string, required: boolean }) => ({
|
||
key: item.key,
|
||
value: "",
|
||
required: item.required
|
||
}),
|
||
),
|
||
});
|
||
} catch (error) {
|
||
console.error(error);
|
||
}
|
||
}
|
||
|
||
useShallowEffect(() => {
|
||
mutate();
|
||
}, []);
|
||
|
||
useShallowEffect(() => {
|
||
if (listCategory.length > 0) {
|
||
onGetJenisSurat();
|
||
}
|
||
}, [jenisSurat, listCategory]);
|
||
|
||
useShallowEffect(() => {
|
||
if (jenisSuratFix.id != "") {
|
||
getDetailJenisSurat();
|
||
}
|
||
}, [jenisSuratFix.id]);
|
||
|
||
function onChecking() {
|
||
const hasError = Object.values(errors).some((v) => v);
|
||
|
||
if (hasError) {
|
||
return notification({
|
||
title: "Gagal",
|
||
message: "Masih ada form yang belum valid",
|
||
type: "error",
|
||
});
|
||
}
|
||
|
||
const isFormKosong = Object.values(formSurat).some((value) => {
|
||
if (Array.isArray(value)) {
|
||
return value.some(
|
||
(item) =>
|
||
(typeof item.value === "string" && item.value.trim() === "" && item.required) || (typeof item.value === "object" && item.value === null && item.required),
|
||
);
|
||
}
|
||
return typeof value === "string" && value.trim() === "";
|
||
});
|
||
|
||
if (isFormKosong) {
|
||
return notification({
|
||
title: "Gagal",
|
||
message: "Silahkan lengkapi form surat",
|
||
type: "error",
|
||
});
|
||
}
|
||
|
||
open();
|
||
}
|
||
|
||
async function onSubmit() {
|
||
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) {
|
||
setNoPengajuan(res.data?.noPengajuan || "");
|
||
} 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 validateField(key: string, value: any) {
|
||
const stringValue = String(value ?? "").trim();
|
||
|
||
// wajib diisi
|
||
if (!stringValue) {
|
||
return "Field wajib diisi";
|
||
}
|
||
|
||
// 🔥 semua key yang mengandung "nik"
|
||
if (key.toLowerCase().includes("nik")) {
|
||
if (!/^\d+$/.test(stringValue)) {
|
||
return "NIK harus berupa angka";
|
||
}
|
||
|
||
if (stringValue.length !== 16) {
|
||
return "NIK harus 16 digit";
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
|
||
function validationForm({
|
||
key,
|
||
value,
|
||
}: {
|
||
key: "nama" | "phone" | "dataPelengkap" | "syaratDokumen";
|
||
value: any;
|
||
}) {
|
||
if (key === "dataPelengkap" || key === "syaratDokumen") {
|
||
if (value.required == true) {
|
||
const errorMsg = validateField(value.key, value.value);
|
||
|
||
setErrors((prev) => ({
|
||
...prev,
|
||
[value.key]: errorMsg,
|
||
}));
|
||
}
|
||
setFormSurat((prev) => ({
|
||
...prev,
|
||
[key]: updateArrayByKey(prev[key], value.key, value.value),
|
||
}));
|
||
} else {
|
||
const keyFix = key == "nama" ? "nama_kontak" : key;
|
||
const errorMsg = validateField(keyFix, value);
|
||
|
||
setErrors((prev) => ({
|
||
...prev,
|
||
[keyFix]: errorMsg,
|
||
}));
|
||
|
||
setFormSurat({
|
||
...formSurat,
|
||
[key]: value,
|
||
});
|
||
}
|
||
}
|
||
|
||
|
||
return (
|
||
<Container size="md" w={"100%"} pb={"lg"}>
|
||
<Modal
|
||
opened={opened}
|
||
onClose={close}
|
||
title={"Konfirmasi"}
|
||
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
|
||
>
|
||
<Stack gap="sm">
|
||
<Text>Apakah anda yakin ingin mengirim pengajuan surat ini?</Text>
|
||
<Group justify="center" grow>
|
||
<Button variant="light" onClick={close}>
|
||
Tidak
|
||
</Button>
|
||
<Button
|
||
variant="filled"
|
||
color="green"
|
||
onClick={() => {
|
||
onSubmit();
|
||
close();
|
||
}}
|
||
>
|
||
Ya
|
||
</Button>
|
||
</Group>
|
||
</Stack>
|
||
</Modal>
|
||
<FullScreenLoading visible={submitLoading} />
|
||
{noPengajuan != "" ? (
|
||
<SuccessPengajuan
|
||
noPengajuan={noPengajuan}
|
||
onClose={() => {
|
||
onResetAll();
|
||
navigate("/darmasaba/surat");
|
||
}}
|
||
category="create"
|
||
/>
|
||
) : (
|
||
<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: 4 Sections</Badge>
|
||
</Group>
|
||
</Group>
|
||
<Stack gap="lg">
|
||
<FormSection
|
||
title="Jenis Surat Pengajuan"
|
||
icon={<IconCategory size={16} />}
|
||
>
|
||
<Grid>
|
||
<Grid.Col span={12}>
|
||
<Select
|
||
allowDeselect={false}
|
||
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 == "" ? null : jenisSuratFix.name}
|
||
onChange={(value) => {
|
||
const slug = toSlug(String(value));
|
||
navigate("/darmasaba/surat?jenis=" + slug);
|
||
}}
|
||
/>
|
||
</Grid.Col>
|
||
</Grid>
|
||
</FormSection>
|
||
|
||
{/* Kontak Section */}
|
||
<FormSection
|
||
title="Kontak"
|
||
icon={<IconPhone size={16} />}
|
||
description="Informasi kontak yg dapat dihubungi"
|
||
>
|
||
<Grid>
|
||
<Grid.Col span={6}>
|
||
<TextInput
|
||
label={<FieldLabel label="Nama" hint="Nama kontak" required />}
|
||
placeholder="Budi Setiawan"
|
||
value={formSurat.nama}
|
||
error={errors.nama_kontak}
|
||
onChange={(e) =>
|
||
validationForm({ key: "nama", value: e.target.value })
|
||
}
|
||
/>
|
||
</Grid.Col>
|
||
|
||
<Grid.Col span={6}>
|
||
<TextInput
|
||
label={
|
||
<FieldLabel
|
||
required
|
||
label="Nomor Telephone"
|
||
hint="Nomor telephone yang dapat dihubungi / terhubung dengan whatsapp"
|
||
/>
|
||
}
|
||
placeholder="08123456789"
|
||
value={formSurat.phone}
|
||
error={errors.phone}
|
||
type="number"
|
||
onChange={(e) =>
|
||
validationForm({ key: "phone", value: e.target.value })
|
||
}
|
||
/>
|
||
</Grid.Col>
|
||
</Grid>
|
||
</FormSection>
|
||
|
||
{jenisSuratFix.id != "" &&
|
||
dataSurat &&
|
||
dataSurat.dataPelengkap && (
|
||
<>
|
||
<FormSection
|
||
title="Data Yang Diperlukan"
|
||
description="Data yang diperlukan untuk mengajukan surat"
|
||
icon={<IconNotes size={16} />}
|
||
>
|
||
<Grid>
|
||
{dataSurat.dataPelengkap.map(
|
||
(item: any, index: number) => (
|
||
<Grid.Col span={6} key={index}>
|
||
{item.type == "enum" ? (
|
||
<Select
|
||
allowDeselect={false}
|
||
label={
|
||
<FieldLabel
|
||
label={item.name}
|
||
hint={item.desc}
|
||
required={item.required}
|
||
/>
|
||
}
|
||
data={item.options ?? []}
|
||
placeholder={item.name}
|
||
onChange={(e) => {
|
||
validationForm({
|
||
key: "dataPelengkap",
|
||
value: { key: item.key, value: e, required: item.required },
|
||
});
|
||
}}
|
||
value={
|
||
formSurat.dataPelengkap.find(
|
||
(n: any) => n.key == item.key,
|
||
)?.value
|
||
}
|
||
/>
|
||
) : item.type == "date" ? (
|
||
<DateInput
|
||
locale="id"
|
||
valueFormat="DD MMMM YYYY"
|
||
label={
|
||
<FieldLabel
|
||
label={item.name}
|
||
hint={item.desc}
|
||
required={item.required}
|
||
/>
|
||
}
|
||
placeholder={item.name}
|
||
onChange={(e) => {
|
||
const formatted = e
|
||
? dayjs(e)
|
||
.locale("id")
|
||
.format("DD MMMM YYYY")
|
||
: "";
|
||
validationForm({
|
||
key: "dataPelengkap",
|
||
value: {
|
||
key: item.key,
|
||
value: formatted,
|
||
required: item.required,
|
||
},
|
||
});
|
||
}}
|
||
/>
|
||
) : (
|
||
<TextInput
|
||
error={errors[item.key]}
|
||
type={item.type}
|
||
label={
|
||
<FieldLabel
|
||
label={item.name}
|
||
hint={item.desc}
|
||
required={item.required}
|
||
/>
|
||
}
|
||
placeholder={item.name}
|
||
onChange={(e) =>
|
||
validationForm({
|
||
key: "dataPelengkap",
|
||
value: {
|
||
key: item.key,
|
||
value: e.target.value,
|
||
required: item.required,
|
||
},
|
||
})
|
||
}
|
||
value={
|
||
formSurat.dataPelengkap.find(
|
||
(n: any) => n.key == item.key,
|
||
)?.value
|
||
}
|
||
/>
|
||
)}
|
||
</Grid.Col>
|
||
),
|
||
)}
|
||
</Grid>
|
||
</FormSection>
|
||
|
||
<FormSection
|
||
title="Syarat Dokumen"
|
||
description="Syarat dokumen yang diperlukan"
|
||
icon={<IconFiles size={16} />}
|
||
>
|
||
<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}
|
||
required={item.required}
|
||
/>
|
||
</Grid.Col>
|
||
),
|
||
)}
|
||
</Grid>
|
||
</FormSection>
|
||
|
||
{/* Actions */}
|
||
<Group justify="right" mt="md">
|
||
{/* <Button variant="default" onClick={() => { }}>
|
||
Reset
|
||
</Button> */}
|
||
<Button onClick={onChecking}>Kirim</Button>
|
||
</Group>
|
||
</>
|
||
)}
|
||
</Stack>
|
||
</Stack>
|
||
</Box>
|
||
)}
|
||
</Container>
|
||
);
|
||
}
|
||
|
||
function FieldLabel({ label, hint, required = false, }: { label: string; hint?: string; required?: boolean; }) {
|
||
return (
|
||
<Group justify="apart" gap="xs" align="center">
|
||
<Group gap={4} align="center">
|
||
<Text fw={600}>
|
||
{label}
|
||
{required && (
|
||
<Text span c="red" ml={4}>
|
||
*
|
||
</Text>
|
||
)}
|
||
</Text>
|
||
</Group>
|
||
|
||
{hint && (
|
||
<Tooltip label={hint} withArrow>
|
||
<ActionIcon size={24} variant="subtle">
|
||
<IconInfoCircle size={16} />
|
||
</ActionIcon>
|
||
</Tooltip>
|
||
)}
|
||
</Group>
|
||
);
|
||
}
|
||
|
||
|
||
function FormSection({
|
||
title,
|
||
icon,
|
||
children,
|
||
description,
|
||
}: {
|
||
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>
|
||
|
||
<Divider mb="sm" />
|
||
<Stack gap="sm">{children}</Stack>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
function FileInputWrapper({
|
||
label,
|
||
placeholder,
|
||
accept,
|
||
onChange,
|
||
preview,
|
||
name,
|
||
description,
|
||
required = false,
|
||
}: {
|
||
label: string;
|
||
placeholder?: string;
|
||
accept?: string;
|
||
onChange: (file: File | null) => void;
|
||
preview?: string | null;
|
||
name: string;
|
||
description?: string;
|
||
required?: boolean;
|
||
}) {
|
||
return (
|
||
<Stack gap="xs">
|
||
<Flex direction={"column"}>
|
||
<Group justify="apart" align="center">
|
||
<Text fw={500}>
|
||
{label}
|
||
{required && (
|
||
<Text span c="red" ml={4}>
|
||
*
|
||
</Text>
|
||
)}
|
||
</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}
|
||
clearable={true}
|
||
/>
|
||
|
||
{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>
|
||
);
|
||
}
|