upd: form surat #90

Merged
amaliadwiy merged 1 commits from amalia/17-des-25 into main 2025-12-17 17:40:40 +08:00
3 changed files with 354 additions and 266 deletions

View File

@@ -1,4 +1,6 @@
import notification from "@/components/notificationGlobal";
import apiFetch from "@/lib/apiFetch"; import apiFetch from "@/lib/apiFetch";
import { capitalizeWords, fromSlug, toSlug } from "@/server/lib/slug_converter";
import { import {
ActionIcon, ActionIcon,
Badge, Badge,
@@ -7,87 +9,151 @@ import {
Card, Card,
Container, Container,
Divider, Divider,
FileButton,
Grid, Grid,
Group, Group,
Select, Select,
Stack, Stack,
Text, Text,
TextInput, TextInput,
Textarea,
Tooltip Tooltip
} from "@mantine/core"; } from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks"; import { useShallowEffect } from "@mantine/hooks";
import { import {
IconBuildingCommunity, IconBuildingCommunity,
IconInfoCircle, IconInfoCircle,
IconMapPin, IconUpload,
IconUser IconUser
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import React from "react"; import React, { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import useSWR from "swr"; import useSWR from "swr";
// ========================= type DataItem = {
// Reusable UI components jenis: string;
// ========================= value: string;
};
function FieldLabel({ label, hint }: { label: string; hint?: string }) { type FormSurat = {
return ( kategoryId: string;
<Group justify="apart" gap="xs" align="center"> nama: string;
<Text fw={600}>{label}</Text> phone: string;
{hint && ( dataText: DataItem[];
<Tooltip label={hint} withArrow> syaratDokumen: DataItem[];
<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>
);
}
// =========================
// Main form component
// =========================
export default function FormSurat() { export default function FormSurat() {
const navigate = useNavigate();
const { search } = useLocation();
const query = new URLSearchParams(search);
const jenisSurat = query.get("jenis");
const { data, mutate, isLoading } = useSWR("category-pelayanan-list", () => const { data, mutate, isLoading } = useSWR("category-pelayanan-list", () =>
apiFetch.api.pelayanan.category.get(), apiFetch.api.pelayanan.category.get(),
); );
const [jenisSuratFix, setJenisSuratFix] = useState({ name: "", id: "" });
const [dataSurat, setDataSurat] = useState<any>({})
const [formSurat, setFormSurat] = useState<FormSurat>({
nama: "",
phone: "",
kategoryId: "",
dataText: [],
syaratDokumen: [],
})
const listCategory = data?.data || []; const listCategory = data?.data || [];
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({
kategoryId: jenisSuratFix.id,
nama: "",
phone: "",
dataText: (get.data?.dataText || []).map((item: string) => ({
jenis: item,
value: "",
})),
syaratDokumen: (get.data?.syaratDokumen || []).map(
(item: { name: string }) => ({
jenis: item.name,
value: "",
})
),
});
} catch (error) {
console.error(error);
}
}
useShallowEffect(() => { useShallowEffect(() => {
mutate(); mutate();
}, []); }, []);
useShallowEffect(() => {
if (listCategory.length > 0) {
onGetJenisSurat();
}
}, [jenisSurat, listCategory]);
useShallowEffect(() => {
if (jenisSuratFix.id != "") {
getDetailJenisSurat();
}
}, [jenisSuratFix.id]);
function onSubmit() {
const isFormKosong = Object.values(formSurat).some((value) => {
if (Array.isArray(value)) {
return (
value.length === 0 ||
value.some((item) => !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",
});
}
console.log("READY SUBMIT", formSurat);
}
return ( return (
<Container size="md" w={"100%"}> <Container size="md" w={"100%"}>
<Box> <Box>
@@ -110,8 +176,6 @@ export default function FormSurat() {
<Badge radius="sm">Form Length: 3 Sections</Badge> <Badge radius="sm">Form Length: 3 Sections</Badge>
</Group> </Group>
</Group> </Group>
<form>
<Stack gap="lg"> <Stack gap="lg">
{/* Header Section */} {/* Header Section */}
<FormSection <FormSection
@@ -149,170 +213,78 @@ export default function FormSurat() {
label={<FieldLabel label="Jenis Surat" hint="Jenis surat yang ingin diajukan" />} label={<FieldLabel label="Jenis Surat" hint="Jenis surat yang ingin diajukan" />}
placeholder="Pilih jenis surat" placeholder="Pilih jenis surat"
data={listCategory.map((item: any) => ({ data={listCategory.map((item: any) => ({
value: item.id, value: item.name,
label: item.name, label: item.name,
}))} }))}
value={jenisSuratFix.name}
onChange={(value) => {
const slug = toSlug(String(value))
navigate("/darmasaba/surat?jenis=" + slug)
}}
/> />
</Grid.Col> </Grid.Col>
</Grid> </Grid>
</FormSection> </FormSection>
{
jenisSuratFix.id != "" && dataSurat && dataSurat.dataText &&
<>
<FormSection
title="Data Pelengkap"
description="Data pelengkap yang diperlukan"
>
<Grid>
{
dataSurat.dataText.map((item: any, index: number) => (
<Grid.Col span={6} key={index}>
<TextInput
label={
<FieldLabel
label={dataSurat.dataText[index] == "nik" ? "NIK" : capitalizeWords(dataSurat.dataText[index])}
/>
}
placeholder={dataSurat.dataText[index] == "nik" ? "NIK" : capitalizeWords(dataSurat.dataText[index])}
/>
</Grid.Col>
))
}
</Grid>
</FormSection>
<FormSection <FormSection
title="Syarat Dokumen" title="Syarat Dokumen"
description="Syarat dokumen yang diperlukan" description="Syarat dokumen yang diperlukan"
> >
<Grid> <Grid>
<Grid.Col span={6}> {
<TextInput dataSurat.syaratDokumen.map((item: any, index: number) => (
label={ <Grid.Col span={6} key={index}>
<FieldLabel <FieldLabelUpload
label="Nama Lengkap" label={item.desc}
hint="Sesuai KTP"
/> />
} <FileButton
placeholder="Nama lengkap" onChange={async (file) => {
/> if (!file) return;
</Grid.Col> // const base64 = await fileToBase64(file);
// form.setFieldValue("foto", base64);
<Grid.Col span={6}> // setFotoName(file.name);
<TextInput }}
label={ accept="image/*"
<FieldLabel
label="NIK"
hint="16 digit, tanpa spasi"
/>
}
placeholder="3201xxxxxxxxxxxx"
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label={
<FieldLabel
label="Tempat, Tanggal Lahir"
hint="Contoh: Denpasar, 31-12-1990"
/>
}
placeholder="Tempat, tanggal lahir"
/>
</Grid.Col>
<Grid.Col span={6}>
<Select
label={<FieldLabel label="Jenis Kelamin" />}
placeholder="Pilih jenis kelamin"
data={["Laki-laki", "Perempuan"]}
/>
</Grid.Col>
<Grid.Col span={6}>
<Select
label={<FieldLabel label="Agama" />}
placeholder="Pilih agama"
data={[
"Islam",
"Kristen",
"Katolik",
"Hindu",
"Buddha",
"Konghucu",
"Lainnya",
]}
/>
</Grid.Col>
<Grid.Col span={6}>
<Select
label={<FieldLabel label="Status Perkawinan" />}
placeholder="Pilih status"
data={[
"Belum Kawin",
"Kawin",
"Cerai Hidup",
"Cerai Mati",
]}
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label={<FieldLabel label="Pekerjaan" />}
placeholder="Pekerjaan"
/>
</Grid.Col>
<Grid.Col span={12}>
<Textarea
label={<FieldLabel label="Alamat Lengkap" />}
placeholder="Alamat domisili"
minRows={2}
/>
</Grid.Col>
<Grid.Col span={2}>
<TextInput
label={<FieldLabel label="RT" />}
placeholder="001"
/>
</Grid.Col>
<Grid.Col span={2}>
<TextInput
label={<FieldLabel label="RW" />}
placeholder="002"
/>
</Grid.Col>
<Grid.Col span={4}>
<TextInput
label={<FieldLabel label="Desa / Kelurahan" />}
placeholder="Desa"
/>
</Grid.Col>
<Grid.Col span={4}>
<TextInput
label={<FieldLabel label="Kecamatan" />}
placeholder="Kecamatan"
/>
</Grid.Col>
<Grid.Col span={4}>
<TextInput
label={<FieldLabel label="Kabupaten / Kota" />}
placeholder="Kabupaten / Kota"
/>
</Grid.Col>
</Grid>
</FormSection>
{/* Keterangan Section */}
<FormSection
title="Keterangan"
icon={<IconMapPin size={18} />}
description="Isi pernyataan SKTM"
> >
<Textarea {(props) => (
label={ <Button
<FieldLabel leftSection={<IconUpload size={16} />}
label="Isi Surat" {...props}
hint="Jelaskan kondisi ekonomi secara singkat" mt="sm"
/> >
Upload File
</Button>
)}
</FileButton>
</Grid.Col>
))
} }
placeholder="Pernyataan resmi bahwa yang bersangkutan benar-benar tergolong keluarga tidak mampu..." </Grid>
minRows={4}
/>
<TextInput
label={
<FieldLabel
label="Keperluan"
hint="Contoh: Beasiswa pendidikan / Perawatan kesehatan"
/>
}
placeholder="Keperluan surat"
/>
</FormSection> </FormSection>
{/* Actions */} {/* Actions */}
@@ -320,12 +292,86 @@ export default function FormSurat() {
<Button variant="default" onClick={() => { }}> <Button variant="default" onClick={() => { }}>
Reset Reset
</Button> </Button>
<Button type="submit">Kirim / Simpan</Button> <Button onClick={onSubmit}>Kirim</Button>
</Group> </Group>
</>
}
</Stack> </Stack>
</form>
</Stack> </Stack>
</Box> </Box>
</Container> </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 FieldLabelUpload({
label,
description,
}: {
label: React.ReactNode;
description?: string;
}) {
return (
<div>
<Group justify="apart" style={{ width: "100%" }}>
<Group gap={6}>
<Text size="sm" fw={600}>
{label}
</Text>
{description && (
<ActionIcon size={18} variant="subtle" aria-hidden>
<IconInfoCircle size={16} />
</ActionIcon>
)}
</Group>
</Group>
{description && (
<Text size="sm" c="dimmed" mt={4} style={{ lineHeight: 1.2 }}>
{description}
</Text>
)}
</div>
);
}
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>
);
}

View File

@@ -0,0 +1,18 @@
export function toSlug(text: string): string {
return encodeURIComponent(
text
.toLowerCase()
.trim()
.replace(/\s+/g, "-")
);
}
export function fromSlug(slug: string): string {
return decodeURIComponent(slug)
.replace(/-/g, " ")
.replace(/\b\w/g, c => c.toUpperCase());
}
export function capitalizeWords(text: string): string {
return text.replace(/\b\w/g, c => c.toUpperCase());
}

View File

@@ -110,7 +110,31 @@ const PelayananRoute = new Elysia({
} }
}) })
return data if (!data) {
return;
}
const dataText: string[] = Array.isArray(data.dataText)
? data.dataText.filter((v): v is string => typeof v === "string")
: [];
const syaratDokumen: { name: string }[] = Array.isArray(data.syaratDokumen)
? data.syaratDokumen.filter(
(v): v is { name: string } =>
typeof v === "object" &&
v !== null &&
"name" in v &&
typeof (v as any).name === "string"
)
: [];
return {
id: data.id,
name: data.name,
dataText,
syaratDokumen,
};
}, { }, {
query: t.Object({ query: t.Object({
id: t.String({ minLength: 1, error: "id harus diisi" }), id: t.String({ minLength: 1, error: "id harus diisi" }),