This commit is contained in:
bipproduction
2025-10-12 21:49:54 +08:00
parent 86d5b435f7
commit 9850fab34d
44 changed files with 8533 additions and 2108 deletions

View File

@@ -1,9 +1,7 @@
export default function Home() {
return (
<div>
<h1>Home</h1>
</div>
);
return (
<div>
<h1>Home</h1>
</div>
);
}

View File

@@ -1,46 +1,63 @@
import { Button, Container, Group, Stack, Text, TextInput } from "@mantine/core";
import {
Button,
Container,
Group,
Stack,
Text,
TextInput,
} from "@mantine/core";
import { useState } from "react";
import apiFetch from "../lib/apiFetch";
export default function Login() {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [loading, setLoading] = useState(false)
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async () => {
setLoading(true)
try {
const response = await apiFetch.auth.login.post({
email,
password,
})
const handleSubmit = async () => {
setLoading(true);
try {
const response = await apiFetch.auth.login.post({
email,
password,
});
if (response.data?.token) {
localStorage.setItem('token', response.data.token)
window.location.href = '/dashboard'
return
}
if (response.data?.token) {
localStorage.setItem("token", response.data.token);
window.location.href = "/dashboard";
return;
}
if (response.error) {
alert(JSON.stringify(response.error))
}
} catch (error) {
console.error(error)
} finally {
setLoading(false)
}
if (response.error) {
alert(JSON.stringify(response.error));
}
} catch (error) {
console.error(error);
} finally {
setLoading(false);
}
};
return (
<Container>
<Stack>
<Text>Login</Text>
<TextInput placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} />
<TextInput placeholder="Password" value={password} onChange={(e) => setPassword(e.target.value)} />
<Group justify="right">
<Button onClick={handleSubmit} disabled={loading}>Login</Button>
</Group>
</Stack>
</Container>
)
}
return (
<Container>
<Stack>
<Text>Login</Text>
<TextInput
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<TextInput
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Group justify="right">
<Button onClick={handleSubmit} disabled={loading}>
Login
</Button>
</Group>
</Stack>
</Container>
);
}

View File

@@ -1,9 +1,7 @@
export default function NotFound() {
return (
<div>
<h1>404 Not Found</h1>
</div>
);
return (
<div>
<h1>404 Not Found</h1>
</div>
);
}

View File

@@ -0,0 +1,82 @@
import clientRoutes from "@/clientRoutes";
import { Button, Container, SimpleGrid, Stack, Text } from "@mantine/core";
import { useNavigate } from "react-router-dom";
export default function DarmasabaPage() {
const navigate = useNavigate();
return (
<Container size={"md"} w={"100%"}>
<Stack>
<Text>Form Darmasaba</Text>
<SimpleGrid
cols={{
base: 1,
sm: 2,
md: 3,
}}
>
<Button
variant="outline"
onClick={() => navigate(clientRoutes["/darmasaba/kartu-keluarga"])}
>
Form KK
</Button>
<Button
variant="outline"
onClick={() => navigate(clientRoutes["/darmasaba/kartu-tanda-penduduk"])}
>
Form KTP
</Button>
<Button
variant="outline"
onClick={() => navigate(clientRoutes["/darmasaba/laporan-sampah"])}
>
Form Laporan Sampah
</Button>
<Button
variant="outline"
onClick={() => navigate(clientRoutes["/darmasaba/surat-keterangan-domisili-organisasi"])}
>
Form SKDO
</Button>
<Button
variant="outline"
onClick={() => navigate(clientRoutes["/darmasaba/surat-keterangan-penghasilan"])}
>
Form SKP
</Button>
<Button
variant="outline"
onClick={() => navigate(clientRoutes["/darmasaba/surat-keterangan-tidak-mampu"])}
>
Form SKTM
</Button>
<Button
variant="outline"
onClick={() => navigate(clientRoutes["/darmasaba/surat-keterangan-kelakuan-baik"])}
>
Form SKK
</Button>
<Button
variant="outline"
onClick={() => navigate(clientRoutes["/darmasaba/surat-keterangan-usaha"])}
>
Form SKU
</Button>
<Button
variant="outline"
onClick={() => navigate(clientRoutes["/darmasaba/surat-keterangan-tempat-usaha"])}
>
Form SKTU
</Button>
<Button
variant="outline"
onClick={() => navigate(clientRoutes["/darmasaba/surat-keterangan-belum-kawin"])}
>
Form Belum Kawin
</Button>
</SimpleGrid>
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,5 @@
import { Outlet } from "react-router-dom";
export default function DarmasabaLayout() {
return <Outlet />;
}

View File

@@ -0,0 +1,835 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
Accordion,
ActionIcon,
Avatar,
Badge,
Button,
Card,
Container,
Divider,
Grid,
Group,
Modal,
Paper,
ScrollArea,
Select,
Stack,
Text,
Textarea,
TextInput,
Title,
Tooltip,
} from "@mantine/core";
import { DatePicker } from "@mantine/dates";
import { useForm } from "@mantine/form";
import {
IconCheck,
IconInfoCircle,
IconPlus,
IconTrash,
IconX,
} from "@tabler/icons-react";
import { useState } from "react";
// -----------------------------
// Types derived from provided JSON schema
// -----------------------------
type JenisPermohonan =
| "Baru"
| "Tambah Anggota Keluarga"
| "Pengurangan Anggota Keluarga"
| "Perubahan Data";
type JenisKelamin = "Laki-laki" | "Perempuan";
type Agama =
| "Islam"
| "Kristen"
| "Katolik"
| "Hindu"
| "Buddha"
| "Konghucu"
| "Lainnya";
type StatusHubungan =
| "Kepala Keluarga"
| "Istri"
| "Anak"
| "Orang Tua"
| "Famili Lain"
| "Lainnya";
type StatusPerkawinan = "Belum Kawin" | "Kawin" | "Cerai Hidup" | "Cerai Mati";
type Kewarganegaraan = "WNI" | "WNA";
interface AnggotaKeluargaItem {
no: number;
namaLengkap: string;
nik: string;
jenisKelamin: JenisKelamin | "";
tempatTanggalLahir: string;
agama: Agama | "";
pendidikan: string;
pekerjaan: string;
statusHubungan: StatusHubungan | "";
statusPerkawinan: StatusPerkawinan | "";
kewarganegaraan: Kewarganegaraan | "";
noPasporKitas?: string;
namaAyah?: string;
namaIbu?: string;
}
interface KepalaKeluarga {
namaLengkap: string;
nik: string;
tempatTanggalLahir: string;
alamat: string;
rt: string;
rw: string;
desaKelurahan: string;
kecamatan: string;
kabupatenKota: string;
kodePos: string;
telepon: string;
}
interface Pemohon {
nama: string;
tandaTangan: string;
}
interface Pengesahan {
kepalaDesaLurah: string;
camat: string;
petugasRegistrasi: string;
}
interface KKFormValues {
jenisPermohonan: JenisPermohonan | "";
kepalaKeluarga: KepalaKeluarga;
anggotaKeluarga: AnggotaKeluargaItem[];
pernyataanPemohon: string;
tanggalPengajuan: Date | null;
pemohon: Pemohon;
pengesahan: Pengesahan;
}
// -----------------------------
// Reusable small components
// -----------------------------
function FieldLabel({
label,
description,
}: {
label: React.ReactNode;
description?: string;
}) {
return (
<Group justify="apart" style={{ width: "100%" }}>
<Group gap={6}>
<Text size="sm" fw={600}>
{label}
</Text>
{description && (
<Tooltip label={description} withArrow>
<ActionIcon size="xs" variant="transparent">
<IconInfoCircle size={16} />
</ActionIcon>
</Tooltip>
)}
</Group>
</Group>
);
}
// Render a form field based on a simple schema mapping. This keeps the main component tidy.
function FormField(props: {
children?: React.ReactNode;
label: string;
description?: string;
error?: string | null;
}) {
const { children, label, description, error } = props;
return (
<Stack gap={6} style={{ width: "100%" }}>
<FieldLabel label={label} description={description} />
{children}
{error ? (
<Text size="xs" color="red" aria-live="polite">
{error}
</Text>
) : null}
</Stack>
);
}
// -----------------------------
// Main Dynamic KK Form component
// -----------------------------
export default function DynamicKKForm() {
// Form initialization with sensible defaults — helps user with example values and keyboard navigation.
const form = useForm<KKFormValues>({
initialValues: {
jenisPermohonan: "",
kepalaKeluarga: {
namaLengkap: "",
nik: "",
tempatTanggalLahir: "",
alamat: "",
rt: "",
rw: "",
desaKelurahan: "",
kecamatan: "",
kabupatenKota: "",
kodePos: "",
telepon: "",
},
anggotaKeluarga: [
{
no: 1,
namaLengkap: "",
nik: "",
jenisKelamin: "",
tempatTanggalLahir: "",
agama: "",
pendidikan: "",
pekerjaan: "",
statusHubungan: "",
statusPerkawinan: "",
kewarganegaraan: "",
noPasporKitas: "",
namaAyah: "",
namaIbu: "",
},
],
pernyataanPemohon: "",
tanggalPengajuan: new Date(),
pemohon: { nama: "", tandaTangan: "" },
pengesahan: { kepalaDesaLurah: "", camat: "", petugasRegistrasi: "" },
},
validate: {
// Simple validation rules matching schema descriptions.
jenisPermohonan: (value) => (value ? null : "Pilih jenis permohonan"),
kepalaKeluarga: {
namaLengkap: (v) =>
v && v.length > 1 ? null : "Nama lengkap wajib diisi",
nik: (v) => (/^\d{16}$/.test(v) ? null : "NIK harus 16 digit angka"),
telepon: (v) =>
v && v.length >= 7 ? null : "Masukkan nomor telepon/HP yang valid",
},
pemohon: {
nama: (v) => (v ? null : "Nama pemohon harus diisi"),
},
},
});
// Helper: add a new anggota with next sequential no
function addAnggota() {
const nextNo = form.values.anggotaKeluarga.length + 1;
form.setFieldValue("anggotaKeluarga", [
...form.values.anggotaKeluarga,
{
no: nextNo,
namaLengkap: "",
nik: "",
jenisKelamin: "",
tempatTanggalLahir: "",
agama: "",
pendidikan: "",
pekerjaan: "",
statusHubungan: "",
statusPerkawinan: "",
kewarganegaraan: "",
noPasporKitas: "",
namaAyah: "",
namaIbu: "",
},
]);
}
// Remove anggota by index
function removeAnggota(index: number) {
const list = [...form.values.anggotaKeluarga];
list.splice(index, 1);
// re-number
const renumbered = list.map((a, i) => ({ ...a, no: i + 1 }));
form.setFieldValue("anggotaKeluarga", renumbered);
}
// Submit handler — in production you'd call an API. Here we show console and a modal.
const [submitted, setSubmitted] = useState<any>(null);
const [opened, setOpened] = useState(false);
function handleSubmit(values: KKFormValues) {
// sanitize & prepare payload
const payload = {
...values,
tanggalPengajuan:
values.tanggalPengajuan?.toISOString().slice(0, 10) ?? null,
};
console.log("KK form submitted:", payload);
setSubmitted(payload);
setOpened(true);
}
return (
<Container size="md" w="100%">
<Card shadow="md" radius="md" p="lg">
<Group justify="apart" align="flex-start">
<Group>
<Avatar color="blue" radius="xl">
KK
</Avatar>
<div>
<Title order={3}>Formulir Permohonan Kartu Keluarga (KK)</Title>
<Text size="sm" color="dimmed">
Blangko resmi untuk pengajuan Kartu Keluarga pembuatan,
perubahan, atau penambahan/ pengurangan anggota keluarga.
</Text>
</div>
</Group>
</Group>
<Divider my="sm" />
<form onSubmit={form.onSubmit(handleSubmit)}>
<Stack gap="lg">
{/* Jenis Permohonan */}
<FormField
label="Jenis Permohonan"
description="Jenis permohonan pembuatan atau perubahan KK."
error={form.errors.jenisPermohonan as any}
>
<Select
data={[
"Baru",
"Tambah Anggota Keluarga",
"Pengurangan Anggota Keluarga",
"Perubahan Data",
]}
placeholder="Pilih jenis permohonan"
{...form.getInputProps("jenisPermohonan")}
/>
</FormField>
{/* Kepala Keluarga Section */}
<Accordion
variant="separated"
defaultValue="kepala"
chevronPosition="left"
>
<Accordion.Item value="kepala">
<Accordion.Control>
<Group justify="apart" style={{ width: "100%" }}>
<Group>
<Text fw={700}>Kepala Keluarga</Text>
<Text size="xs" c="dimmed">
Data kepala keluarga sesuai KTP
</Text>
</Group>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Grid>
<Grid.Col span={6}>
<FormField
label="Nama Lengkap"
description="Nama lengkap kepala keluarga sesuai KTP."
error={
(form.errors.kepalaKeluarga as any)
?.namaLengkap as any
}
>
<TextInput
placeholder="Contoh: Budi Santoso"
{...form.getInputProps("kepalaKeluarga.namaLengkap")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={6}>
<FormField
label="NIK"
description="Nomor Induk Kependudukan (16 digit)."
error={(form.errors.kepalaKeluarga as any)?.nik as any}
>
<TextInput
placeholder="16 digit NIK"
{...form.getInputProps("kepalaKeluarga.nik")}
inputMode="numeric"
/>
</FormField>
</Grid.Col>
<Grid.Col span={6}>
<FormField
label="Tempat & Tanggal Lahir"
description="Contoh: Denpasar, 1990-01-01"
>
<TextInput
placeholder="Tempat, yyyy-mm-dd"
{...form.getInputProps(
"kepalaKeluarga.tempatTanggalLahir",
)}
/>
</FormField>
</Grid.Col>
<Grid.Col span={6}>
<FormField
label="Telepon"
description="Nomor HP yang bisa dihubungi"
error={
(form.errors.kepalaKeluarga as any)?.telepon as any
}
>
<TextInput
placeholder="08xx-xxxx-xxxx"
{...form.getInputProps("kepalaKeluarga.telepon")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={12}>
<FormField
label="Alamat Lengkap"
description="Sesuai domisili"
>
<Textarea
placeholder="Alamat lengkap"
minRows={2}
{...form.getInputProps("kepalaKeluarga.alamat")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={3}>
<FormField label="RT" description="Nomor RT">
<TextInput
placeholder="001"
{...form.getInputProps("kepalaKeluarga.rt")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={3}>
<FormField label="RW" description="Nomor RW">
<TextInput
placeholder="002"
{...form.getInputProps("kepalaKeluarga.rw")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={3}>
<FormField
label="Kode Pos"
description="Kode pos wilayah"
>
<TextInput
placeholder="80361"
{...form.getInputProps("kepalaKeluarga.kodePos")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={3}>
<FormField
label="Desa / Kelurahan"
description="Nama desa atau kelurahan"
>
<TextInput
placeholder="Contoh: Kuta"
{...form.getInputProps(
"kepalaKeluarga.desaKelurahan",
)}
/>
</FormField>
</Grid.Col>
<Grid.Col span={6}>
<FormField label="Kecamatan" description="Nama kecamatan">
<TextInput
placeholder="Contoh: Kuta"
{...form.getInputProps("kepalaKeluarga.kecamatan")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={6}>
<FormField
label="Kabupaten / Kota"
description="Nama kabupaten atau kota"
>
<TextInput
placeholder="Contoh: Badung"
{...form.getInputProps(
"kepalaKeluarga.kabupatenKota",
)}
/>
</FormField>
</Grid.Col>
</Grid>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
{/* Anggota Keluarga (array) */}
<Card withBorder radius="md" p="md">
<Group justify="apart" mb="sm">
<Group>
<Text fw={700}>Anggota Keluarga</Text>
<Text size="xs" c="dimmed">
Daftar anggota keluarga dalam KK
</Text>
</Group>
<Group>
<Button
size="xs"
leftSection={<IconPlus size={14} />}
onClick={addAnggota}
aria-label="Tambah anggota"
>
Tambah Anggota
</Button>
</Group>
</Group>
<Stack gap="sm">
{form.values.anggotaKeluarga.map((anggota, idx) => (
<Paper key={idx} withBorder radius="md" p="md">
<Grid align="center">
<Grid.Col span={12}>
<Group justify="apart">
<Group>
<Badge>{`#${anggota.no}`}</Badge>
<Text fw={600} size="sm">
{anggota.namaLengkap || "(Belum diisi)"}
</Text>
</Group>
<Group>
<ActionIcon
color="red"
onClick={() => removeAnggota(idx)}
aria-label={`Hapus anggota ${idx + 1}`}
>
<IconTrash />
</ActionIcon>
</Group>
</Group>
</Grid.Col>
<Grid.Col span={4}>
<FormField
label="Nama Lengkap"
description="Nama lengkap anggota keluarga"
>
<TextInput
placeholder="Contoh: Siti"
{...form.getInputProps(
`anggotaKeluarga.${idx}.namaLengkap`,
)}
/>
</FormField>
</Grid.Col>
<Grid.Col span={4}>
<FormField label="NIK" description="16 digit NIK">
<TextInput
placeholder="NIK"
{...form.getInputProps(
`anggotaKeluarga.${idx}.nik`,
)}
inputMode="numeric"
/>
</FormField>
</Grid.Col>
<Grid.Col span={4}>
<FormField label="Jenis Kelamin">
<Select
data={["Laki-laki", "Perempuan"]}
placeholder="Pilih"
{...form.getInputProps(
`anggotaKeluarga.${idx}.jenisKelamin`,
)}
/>
</FormField>
</Grid.Col>
<Grid.Col span={4}>
<FormField label="Tempat & Tanggal Lahir">
<TextInput
placeholder="Contoh: Denpasar, 1995-03-12"
{...form.getInputProps(
`anggotaKeluarga.${idx}.tempatTanggalLahir`,
)}
/>
</FormField>
</Grid.Col>
<Grid.Col span={4}>
<FormField label="Agama">
<Select
data={[
"Islam",
"Kristen",
"Katolik",
"Hindu",
"Buddha",
"Konghucu",
"Lainnya",
]}
placeholder="Pilih"
{...form.getInputProps(
`anggotaKeluarga.${idx}.agama`,
)}
/>
</FormField>
</Grid.Col>
<Grid.Col span={4}>
<FormField label="Pendidikan">
<TextInput
placeholder="Pendidikan terakhir"
{...form.getInputProps(
`anggotaKeluarga.${idx}.pendidikan`,
)}
/>
</FormField>
</Grid.Col>
<Grid.Col span={4}>
<FormField label="Pekerjaan">
<TextInput
placeholder="Pekerjaan"
{...form.getInputProps(
`anggotaKeluarga.${idx}.pekerjaan`,
)}
/>
</FormField>
</Grid.Col>
<Grid.Col span={4}>
<FormField label="Status Hubungan">
<Select
data={[
"Kepala Keluarga",
"Istri",
"Anak",
"Orang Tua",
"Famili Lain",
"Lainnya",
]}
placeholder="Pilih"
{...form.getInputProps(
`anggotaKeluarga.${idx}.statusHubungan`,
)}
/>
</FormField>
</Grid.Col>
<Grid.Col span={4}>
<FormField label="Status Perkawinan">
<Select
data={[
"Belum Kawin",
"Kawin",
"Cerai Hidup",
"Cerai Mati",
]}
placeholder="Pilih"
{...form.getInputProps(
`anggotaKeluarga.${idx}.statusPerkawinan`,
)}
/>
</FormField>
</Grid.Col>
<Grid.Col span={4}>
<FormField label="Kewarganegaraan">
<Select
data={["WNI", "WNA"]}
placeholder="Pilih"
{...form.getInputProps(
`anggotaKeluarga.${idx}.kewarganegaraan`,
)}
/>
</FormField>
</Grid.Col>
<Grid.Col span={4}>
<FormField label="No Paspor / KITAS">
<TextInput
placeholder="Jika ada"
{...form.getInputProps(
`anggotaKeluarga.${idx}.noPasporKitas`,
)}
/>
</FormField>
</Grid.Col>
<Grid.Col span={4}>
<FormField label="Nama Ayah">
<TextInput
placeholder="Nama ayah"
{...form.getInputProps(
`anggotaKeluarga.${idx}.namaAyah`,
)}
/>
</FormField>
</Grid.Col>
<Grid.Col span={4}>
<FormField label="Nama Ibu">
<TextInput
placeholder="Nama ibu"
{...form.getInputProps(
`anggotaKeluarga.${idx}.namaIbu`,
)}
/>
</FormField>
</Grid.Col>
</Grid>
</Paper>
))}
</Stack>
</Card>
{/* Pernyataan Pemohon, Tanggal, Pemohon, Pengesahan */}
<Grid>
<Grid.Col span={12}>
<FormField
label="Pernyataan Pemohon"
description="Pernyataan kebenaran data oleh pemohon."
>
<Textarea
placeholder="Saya menyatakan bahwa data yang saya berikan adalah benar..."
minRows={3}
{...form.getInputProps("pernyataanPemohon")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={4}>
<FormField
label="Tanggal Pengajuan"
description="Tanggal pengajuan formulir"
>
<DatePicker {...form.getInputProps("tanggalPengajuan")} />
</FormField>
</Grid.Col>
<Grid.Col span={8}>
<Card withBorder radius="md" p="sm">
<Text fw={700} size="sm" mb="xs">
Data Pemohon
</Text>
<Grid>
<Grid.Col span={8}>
<FormField label="Nama Pemohon">
<TextInput
placeholder="Nama lengkap"
{...form.getInputProps("pemohon.nama")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={4}>
<FormField
label="Tanda Tangan (scan)"
description="Unggah file scan tanda tangan jika ada"
>
<TextInput
placeholder="Nama file / URL"
{...form.getInputProps("pemohon.tandaTangan")}
/>
</FormField>
</Grid.Col>
</Grid>
</Card>
</Grid.Col>
<Grid.Col span={12}>
<Card withBorder radius="md" p="sm">
<Text fw={700} size="sm" mb="xs">
Pengesahan
</Text>
<Grid>
<Grid.Col span={4}>
<FormField label="Kepala Desa / Lurah">
<TextInput
placeholder="Nama"
{...form.getInputProps("pengesahan.kepalaDesaLurah")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={4}>
<FormField label="Camat">
<TextInput
placeholder="Nama"
{...form.getInputProps("pengesahan.camat")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={4}>
<FormField label="Petugas Registrasi">
<TextInput
placeholder="Nama"
{...form.getInputProps(
"pengesahan.petugasRegistrasi",
)}
/>
</FormField>
</Grid.Col>
</Grid>
</Card>
</Grid.Col>
</Grid>
{/* Submit / Reset actions */}
<Group justify="flex-end" mt="sm">
<Button
variant="default"
onClick={() => form.reset()}
leftSection={<IconX />}
>
Reset
</Button>
<Button type="submit" leftSection={<IconCheck />}>
Kirim Permohonan
</Button>
</Group>
</Stack>
</form>
</Card>
<Modal
opened={opened}
onClose={() => setOpened(false)}
title="Preview Payload"
size="lg"
>
<ScrollArea style={{ height: 400 }}>
<pre style={{ whiteSpace: "pre-wrap", wordBreak: "break-word" }}>
{JSON.stringify(submitted, null, 2)}
</pre>
</ScrollArea>
</Modal>
</Container>
);
}

View File

@@ -0,0 +1,641 @@
import {
Accordion,
ActionIcon,
Button,
Card,
Container,
Divider,
FileButton,
Grid,
Group,
Select,
Stack,
Text,
Textarea,
TextInput,
} from "@mantine/core";
import { DatePicker } from "@mantine/dates";
import { useForm } from "@mantine/form";
import {
IconBuildingBank,
IconCalendar,
IconCheck,
IconId,
IconInfoCircle,
IconUpload,
IconUser,
IconX,
} from "@tabler/icons-react";
import React, { useState } from "react";
// ---------------------------
// Types - strong typing for schema-driven form
// ---------------------------
type EnumField = {
type: "enum";
options: string[];
description?: string;
};
type PrimitiveField = {
type: "string" | "number" | "boolean";
format?: string; // e.g. date
description?: string;
};
type ObjectField = {
[key: string]: PrimitiveField | EnumField;
};
type KTPSchema = {
formTitle: string;
description?: string;
jenisPermohonan: EnumField;
dataPemohon: ObjectField;
pernyataanPemohon: PrimitiveField;
tanggalPengajuan: PrimitiveField & { format?: string };
pengesahan: ObjectField;
};
// ---------------------------
// Helper: convert file to base64 (used for foto/tandaTangan/sidikJari)
// ---------------------------
async function fileToBase64(file: File | null): Promise<string | null> {
if (!file) return null;
return await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = (err) => reject(err);
reader.readAsDataURL(file);
});
}
// ---------------------------
// Reusable small components
// ---------------------------
function FieldLabel({
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>
);
}
// ---------------------------
// Main Form Component
// ---------------------------
const schema: KTPSchema = {
formTitle: "Formulir Permohonan Kartu Tanda Penduduk (KTP)",
description:
"Blangko resmi untuk pengajuan KTP elektronik (e-KTP). Digunakan untuk pembuatan KTP baru, penggantian karena hilang/rusak, atau perubahan data.",
jenisPermohonan: {
type: "enum",
options: [
"Baru",
"Perpanjangan",
"Penggantian Hilang",
"Penggantian Rusak",
"Perubahan Data",
],
description: "Jenis permohonan pembuatan atau perubahan KTP.",
},
dataPemohon: {
namaLengkap: {
type: "string",
description: "Nama lengkap sesuai akta kelahiran.",
},
nik: {
type: "string",
description: "Nomor Induk Kependudukan (16 digit).",
},
jenisKelamin: {
type: "enum",
options: ["Laki-laki", "Perempuan"],
description: "Jenis kelamin pemohon.",
} as any,
tempatTanggalLahir: {
type: "string",
description: "Tempat dan tanggal lahir pemohon.",
},
golonganDarah: {
type: "enum",
options: ["A", "B", "AB", "O", "Tidak Tahu"],
description: "Golongan darah pemohon.",
} as any,
alamat: { type: "string", description: "Alamat lengkap domisili." },
rt: { type: "string", description: "Nomor RT." },
rw: { type: "string", description: "Nomor RW." },
desaKelurahan: {
type: "string",
description: "Nama desa atau kelurahan tempat tinggal.",
},
kecamatan: {
type: "string",
description: "Nama kecamatan tempat tinggal.",
},
kabupatenKota: { type: "string", description: "Nama kabupaten atau kota." },
agama: {
type: "enum",
options: [
"Islam",
"Kristen",
"Katolik",
"Hindu",
"Buddha",
"Konghucu",
"Lainnya",
],
description: "Agama pemohon.",
} as any,
statusPerkawinan: {
type: "enum",
options: ["Belum Kawin", "Kawin", "Cerai Hidup", "Cerai Mati"],
description: "Status perkawinan pemohon.",
} as any,
pekerjaan: { type: "string", description: "Jenis pekerjaan pemohon." },
kewarganegaraan: {
type: "enum",
options: ["WNI", "WNA"],
description: "Kewarganegaraan pemohon.",
} as any,
foto: {
type: "string",
description: "File foto pemohon ukuran 4x6 (upload path/base64).",
},
tandaTangan: {
type: "string",
description: "Tanda tangan digital pemohon (upload path/base64).",
},
sidikJari: {
type: "string",
description: "Hasil rekam sidik jari pemohon (scan/file).",
},
},
pernyataanPemohon: {
type: "string",
description: "Pernyataan bahwa data yang diberikan benar dan sah.",
},
tanggalPengajuan: {
type: "string",
format: "date",
description: "Tanggal pengajuan formulir.",
},
pengesahan: {
petugasRegistrasi: {
type: "string",
description: "Nama petugas registrasi kependudukan yang memproses.",
},
kepalaDinas: {
type: "string",
description:
"Nama Kepala Dinas Kependudukan dan Catatan Sipil yang mengesahkan.",
},
},
};
export default function FormKartuTandaPenduduk() {
// Initial values - sensible defaults / smart placeholders
const form = useForm({
initialValues: {
jenisPermohonan: schema.jenisPermohonan.options[0],
// dataPemohon
namaLengkap: "",
nik: "",
jenisKelamin: "",
tempatTanggalLahir: "",
golonganDarah: "",
alamat: "",
rt: "",
rw: "",
desaKelurahan: "",
kecamatan: "",
kabupatenKota: "",
agama: "",
statusPerkawinan: "",
pekerjaan: "",
kewarganegaraan: "WNI",
foto: null as string | null,
tandaTangan: null as string | null,
sidikJari: null as string | null,
pernyataanPemohon:
"Saya menyatakan data yang saya berikan adalah benar dan sah.",
tanggalPengajuan: null as Date | null,
petugasRegistrasi: "",
kepalaDinas: "",
},
validate: {
namaLengkap: (value) => (!value ? "Nama lengkap harus diisi" : null),
nik: (value) => {
if (!value) return "NIK harus diisi";
const digits = value.replace(/\D/g, "");
if (digits.length !== 16) return "NIK harus 16 digit";
return null;
},
jenisKelamin: (v) => (!v ? "Pilih jenis kelamin" : null),
alamat: (v) => (!v ? "Alamat harus diisi" : null),
pernyataanPemohon: (v) =>
!v || v.length < 10
? "Pernyataan harus diisi minimal 10 karakter"
: null,
tanggalPengajuan: (v) => (!v ? "Pilih tanggal pengajuan" : null),
},
});
// local UI state for file upload previews
const [fotoName, setFotoName] = useState<string | null>(null);
const [ttdName, setTtdName] = useState<string | null>(null);
const [sidikName, setSidikName] = useState<string | null>(null);
// submit handler - in real app this would call an API
const handleSubmit = async (values: typeof form.values) => {
// For demo: convert any stored File objects into base64 is handled at selection time.
// Compose payload
const payload = {
...values,
tanggalPengajuan: values.tanggalPengajuan
? values.tanggalPengajuan.toISOString().slice(0, 10)
: null,
};
// Here you'd normally POST to server
// We'll just console.log and show success
console.log("Submitting KTP form:", payload);
alert("Form submitted — cek console (development).\nNIK: " + values.nik);
};
return (
<Container size="md" w={"100%"}>
<Card shadow="sm" radius="md" p="xl">
<Stack gap="md">
<Group justify="apart">
<Group>
<IconBuildingBank size={28} />
<div>
<Text fw={700} size="lg">
{schema.formTitle}
</Text>
<Text size="sm" c="dimmed">
{schema.description}
</Text>
</div>
</Group>
</Group>
<form
onSubmit={form.onSubmit(async (values) => {
await handleSubmit(values);
form.reset();
})}
>
<Stack gap="lg">
{/* Jenis Permohonan */}
<Card withBorder p="md">
<FieldLabel
label={<span>Jenis Permohonan</span>}
description={schema.jenisPermohonan.description}
/>
<Select
mt="sm"
data={schema.jenisPermohonan.options}
placeholder="Pilih jenis permohonan"
{...form.getInputProps("jenisPermohonan")}
leftSection={<IconId size={16} />}
aria-label="jenis permohonan"
/>
</Card>
{/* Data Pemohon - collapsible */}
<Accordion variant="separated" defaultValue="dataPemohon">
<Accordion.Item value="dataPemohon">
<Accordion.Control icon={<IconUser size={16} />}>
Data Pemohon
</Accordion.Control>
<Accordion.Panel>
<Grid>
<Grid.Col span={6}>
<TextInput
label="Nama Lengkap"
placeholder="Contoh: Budi Santoso"
description={
schema.dataPemohon?.namaLengkap?.description
}
{...form.getInputProps("namaLengkap")}
required
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label="NIK"
placeholder="16 digit NIK"
description={schema.dataPemohon?.nik?.description}
{...form.getInputProps("nik")}
required
/>
</Grid.Col>
<Grid.Col span={6}>
<Select
label="Jenis Kelamin"
placeholder="Pilih..."
data={["Laki-laki", "Perempuan"]}
description={
schema.dataPemohon?.jenisKelamin?.description
}
{...form.getInputProps("jenisKelamin")}
required
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label="Tempat, Tanggal Lahir"
placeholder="Contoh: Denpasar, 01 Januari 1990"
description={
schema.dataPemohon?.tempatTanggalLahir?.description
}
{...form.getInputProps("tempatTanggalLahir")}
/>
</Grid.Col>
<Grid.Col span={6}>
<Select
label="Golongan Darah"
placeholder="Pilih..."
data={["A", "B", "AB", "O", "Tidak Tahu"]}
description={
schema.dataPemohon?.golonganDarah?.description
}
{...form.getInputProps("golonganDarah")}
/>
</Grid.Col>
<Grid.Col span={12}>
<Textarea
label="Alamat"
placeholder="Alamat lengkap domisili"
description={schema.dataPemohon?.alamat?.description}
autosize
minRows={2}
{...form.getInputProps("alamat")}
/>
</Grid.Col>
<Grid.Col span={3}>
<TextInput label="RT" {...form.getInputProps("rt")} />
</Grid.Col>
<Grid.Col span={3}>
<TextInput label="RW" {...form.getInputProps("rw")} />
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label="Desa / Kelurahan"
{...form.getInputProps("desaKelurahan")}
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label="Kecamatan"
{...form.getInputProps("kecamatan")}
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label="Kabupaten / Kota"
{...form.getInputProps("kabupatenKota")}
/>
</Grid.Col>
<Grid.Col span={6}>
<Select
label="Agama"
data={[
"Islam",
"Kristen",
"Katolik",
"Hindu",
"Buddha",
"Konghucu",
"Lainnya",
]}
{...form.getInputProps("agama")}
/>
</Grid.Col>
<Grid.Col span={6}>
<Select
label="Status Perkawinan"
data={[
"Belum Kawin",
"Kawin",
"Cerai Hidup",
"Cerai Mati",
]}
{...form.getInputProps("statusPerkawinan")}
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label="Pekerjaan"
{...form.getInputProps("pekerjaan")}
/>
</Grid.Col>
<Grid.Col span={6}>
<Select
label="Kewarganegaraan"
data={["WNI", "WNA"]}
{...form.getInputProps("kewarganegaraan")}
/>
</Grid.Col>
{/* Uploads: foto, tanda tangan, sidik jari */}
<Grid.Col span={4}>
<FieldLabel
label={<span>Foto (4x6)</span>}
description={schema.dataPemohon?.foto?.description}
/>
<FileButton
onChange={async (file) => {
if (!file) return;
const base64 = await fileToBase64(file);
form.setFieldValue("foto", base64);
setFotoName(file.name);
}}
accept="image/*"
>
{(props) => (
<Button
leftSection={<IconUpload size={16} />}
{...props}
mt="sm"
>
{fotoName || "Upload Foto"}
</Button>
)}
</FileButton>
</Grid.Col>
<Grid.Col span={4}>
<FieldLabel
label={<span>Tanda Tangan</span>}
description={
schema.dataPemohon?.tandaTangan?.description
}
/>
<FileButton
onChange={async (file) => {
if (!file) return;
const base64 = await fileToBase64(file);
form.setFieldValue("tandaTangan", base64);
setTtdName(file.name);
}}
accept="image/*"
>
{(props) => (
<Button
leftSection={<IconUpload size={16} />}
{...props}
mt="sm"
>
{ttdName || "Upload TTD"}
</Button>
)}
</FileButton>
</Grid.Col>
<Grid.Col span={4}>
<FieldLabel
label={<span>Sidik Jari</span>}
description={
schema.dataPemohon?.sidikJari?.description
}
/>
<FileButton
onChange={async (file) => {
if (!file) return;
const base64 = await fileToBase64(file);
form.setFieldValue("sidikJari", base64);
setSidikName(file.name);
}}
accept="image/*,application/pdf"
>
{(props) => (
<Button
leftSection={<IconUpload size={16} />}
{...props}
mt="sm"
>
{sidikName || "Upload Sidik Jari"}
</Button>
)}
</FileButton>
</Grid.Col>
</Grid>
</Accordion.Panel>
</Accordion.Item>
{/* Pernyataan Pemohon */}
<Accordion.Item value="pernyataanPemohon">
<Accordion.Control icon={<IconInfoCircle size={16} />}>
Pernyataan Pemohon
</Accordion.Control>
<Accordion.Panel>
<Textarea
label="Pernyataan"
autosize
minRows={3}
{...form.getInputProps("pernyataanPemohon")}
/>
</Accordion.Panel>
</Accordion.Item>
{/* Tanggal Pengajuan */}
<Accordion.Item value="tanggal">
<Accordion.Control icon={<IconCalendar size={16} />}>
Tanggal Pengajuan
</Accordion.Control>
<Accordion.Panel>
<DatePicker {...form.getInputProps("tanggalPengajuan")} />
</Accordion.Panel>
</Accordion.Item>
{/* Pengesahan */}
<Accordion.Item value="pengesahan">
<Accordion.Control icon={<IconBuildingBank size={16} />}>
Pengesahan
</Accordion.Control>
<Accordion.Panel>
<Grid>
<Grid.Col span={6}>
<TextInput
label="Petugas Registrasi"
{...form.getInputProps("petugasRegistrasi")}
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label="Kepala Dinas"
{...form.getInputProps("kepalaDinas")}
/>
</Grid.Col>
</Grid>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
{/* Action Buttons */}
<Divider />
<Group justify="right" gap="sm">
<Button
variant="default"
onClick={() => form.reset()}
leftSection={<IconX size={16} />}
>
Reset
</Button>
<Button type="submit" leftSection={<IconCheck size={16} />}>
Submit
</Button>
</Group>
<Text size="xs" c="dimmed">
Tip: Semua input penting memiliki validasi inline. NIK harus 16
digit.
</Text>
</Stack>
</form>
</Stack>
</Card>
</Container>
);
}

View File

@@ -0,0 +1,945 @@
import {
Accordion,
ActionIcon,
Badge,
Box,
Button,
Card,
Container,
Divider,
Grid,
Group,
NumberInput,
Select,
Stack,
Text,
TextInput,
Textarea,
Title,
Tooltip,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import {
IconCheck,
IconInfoCircle,
IconPlus,
IconTrash,
IconUser,
IconX,
} from "@tabler/icons-react";
import React from "react";
// Date/Time pickers live in @mantine/dates
import { DatePicker, TimeInput } from "@mantine/dates";
/* ----------------------------- Types ----------------------------- */
/**
* Strongly-typed form shape inferred from provided JSON schema.
* Keep aligned with the JSON schema structure.
*/
type SaksiItem = {
namaLengkap: string;
nik: string;
alamat: string;
};
type Pengesahan = {
kepalaDesaLurah?: string;
camat?: string;
petugasRegistrasi?: string;
};
export type BirthFormValues = {
// dataBayi
dataBayi: {
namaLengkap?: string;
jenisKelamin?: string;
tempatLahir?: string;
tanggalLahir?: Date | null;
jamLahir?: Date | null;
beratBadan?: number | null;
panjangBadan?: number | null;
};
// dataIbu
dataIbu: {
namaLengkap?: string;
nik?: string;
tempatTanggalLahir?: string;
pekerjaan?: string;
alamat?: string;
};
// dataAyah
dataAyah: {
namaLengkap?: string;
nik?: string;
tempatTanggalLahir?: string;
pekerjaan?: string;
alamat?: string;
};
// dataPelapor
dataPelapor: {
namaLengkap?: string;
nik?: string;
hubunganDenganBayi?: string;
alamat?: string;
};
// saksi: array
saksi: SaksiItem[];
// other
keteranganTambahan?: string;
tanggalPelaporan?: Date | null;
pengesahan: Pengesahan;
};
/* ------------------------- Reusable Components ------------------------- */
/**
* FormField: wraps label, description (helper), input control and error UI.
* Keeps consistent spacing/typography and supports left-side icon tooltips when needed.
*/
function FormField({
label,
description,
children,
required = false,
id,
}: {
label: string;
description?: string;
children: React.ReactNode;
required?: boolean;
id?: string;
}) {
return (
<Stack gap="xs" style={{ width: "100%" }}>
<Group justify="apart" gap="xs" align="center" style={{ width: "100%" }}>
<Group gap="xs" align="center">
<Text fw={600} size="sm" component="label" htmlFor={id}>
{label}
</Text>
{description ? (
<Tooltip label={description} withArrow>
<ActionIcon size="sm" aria-label={`${label} info`}>
<IconInfoCircle size={16} />
</ActionIcon>
</Tooltip>
) : null}
</Group>
{required ? <Badge c="red">Wajib</Badge> : null}
</Group>
<Box>{children}</Box>
{description ? (
<Text size="xs" color="dimmed" mt="xs">
{description}
</Text>
) : null}
</Stack>
);
}
/**
* FormSection: card with optional accordion/collapse for nested object grouping.
*/
function FormSection({
title,
subtitle,
icon,
children,
}: {
title: string;
subtitle?: string;
icon?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<Card shadow="sm" radius="md" p="md" withBorder>
<Accordion variant="separated">
<Accordion.Item value="open">
<Accordion.Control>
<Group justify="apart" align="center" gap="md">
<Group gap="sm" align="center">
{icon}
<div>
<Text fw={700}>{title}</Text>
{subtitle ? (
<Text size="xs" color="dimmed">
{subtitle}
</Text>
) : null}
</div>
</Group>
</Group>
</Accordion.Control>
<Accordion.Panel>
<Stack gap="md">{children}</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Card>
);
}
/* --------------------------- Main Component --------------------------- */
export default function FormKeteranganKelahiran() {
// Setup form with sensible defaults (smart defaults: empty strings, null dates)
const form = useForm<BirthFormValues>({
initialValues: {
dataBayi: {
namaLengkap: "",
jenisKelamin: undefined,
tempatLahir: "",
tanggalLahir: null,
jamLahir: null,
beratBadan: null,
panjangBadan: null,
},
dataIbu: {
namaLengkap: "",
nik: "",
tempatTanggalLahir: "",
pekerjaan: "",
alamat: "",
},
dataAyah: {
namaLengkap: "",
nik: "",
tempatTanggalLahir: "",
pekerjaan: "",
alamat: "",
},
dataPelapor: {
namaLengkap: "",
nik: "",
hubunganDenganBayi: "",
alamat: "",
},
saksi: [
// start with one empty witness to guide user
{ namaLengkap: "", nik: "", alamat: "" },
],
keteranganTambahan: "",
tanggalPelaporan: null,
pengesahan: {
kepalaDesaLurah: "",
camat: "",
petugasRegistrasi: "",
},
},
// Validation rules derived from schema and best practices
validate: {
dataBayi: {
namaLengkap: (val) =>
val && val.trim().length > 0 ? null : "Nama bayi diperlukan.",
jenisKelamin: (val) => (val ? null : "Pilih jenis kelamin."),
tanggalLahir: (val) => (val ? null : "Tanggal lahir diperlukan."),
beratBadan: (val) =>
val == null || val > 0 ? null : "Berat harus lebih dari 0.",
panjangBadan: (val) =>
val == null || val > 0 ? null : "Panjang harus lebih dari 0.",
},
dataIbu: {
namaLengkap: (val) =>
val && val.trim().length > 0 ? null : "Nama ibu diperlukan.",
nik: (val) =>
val && /^[0-9]{16}$/.test(val.trim()) ? null : "NIK harus 16 digit.",
},
dataAyah: {
// if father provided, validate NIK format when non-empty
nik: (val) =>
val === "" || /^[0-9]{16}$/.test(val?.trim() || "")
? null
: "NIK harus 16 digit.",
},
dataPelapor: {
namaLengkap: (val) =>
val && val.trim().length > 0 ? null : "Nama pelapor diperlukan.",
nik: (val) =>
val && /^[0-9]{16}$/.test(val.trim()) ? null : "NIK harus 16 digit.",
},
saksi: {
// top-level validation for array minimal length handled in submit
} as any,
tanggalPelaporan: (val) => (val ? null : "Tanggal pelaporan diperlukan."),
},
});
/* ------------------- Dynamic saksi (witness) helpers ------------------- */
const addSaksi = () => {
form.setFieldValue("saksi", [
...form.values.saksi,
{ namaLengkap: "", nik: "", alamat: "" },
]);
};
const removeSaksi = (index: number) => {
const arr = [...form.values.saksi];
arr.splice(index, 1);
form.setFieldValue("saksi", arr);
};
/* ---------------------- Submit / Reset handlers ---------------------- */
const handleSubmit = (values: BirthFormValues) => {
// Extra validation: ensure at least one saksi is filled meaningfully
const hasValidSaksi =
values.saksi.length > 0 &&
values.saksi.some((s) => (s.namaLengkap || "").trim().length > 0);
if (!hasValidSaksi) {
form.setFieldError("saksi", "Minimal satu saksi harus diisi.");
return;
}
// Final normalized payload (dates converted to ISO)
const payload = {
...values,
dataBayi: {
...values.dataBayi,
tanggalLahir: values.dataBayi.tanggalLahir
? values.dataBayi.tanggalLahir.toISOString().split("T")[0]
: null,
jamLahir: values.dataBayi.jamLahir
? values.dataBayi.jamLahir.toISOString().split("T")[1]?.slice(0, 8)
: null,
},
tanggalPelaporan: values.tanggalPelaporan
? values.tanggalPelaporan.toISOString().split("T")[0]
: null,
};
// For demo: print to console. Integrate with API in real app.
// Accessibility: focus the first invalid field if any (not shown here).
console.log("Submitted Birth Certificate Payload:", payload);
// Visual confirmation: we'll set a small success field (in production, use notification)
// Reset form or keep values based on UX decision. We'll keep values and indicate success.
// For example, you can call form.reset() to clear.
// form.reset();
alert("Form berhasil disubmit. Lihat console untuk payload.");
};
const handleReset = () => {
form.reset();
};
/* ------------------------------ Render ------------------------------ */
return (
<Container size={"md"} w={"100%"}>
<Box>
<Stack gap="lg" style={{ maxWidth: 980, margin: "0 auto" }}>
<Group justify="apart">
<Title order={3}>Formulir Surat Keterangan Kelahiran</Title>
<Group>
<Badge variant="light" c="gray">
Blangko resmi
</Badge>
<Text size="sm" color="dimmed">
Blangko untuk pelaporan kelahiran & dasar penerbitan Akta
Kelahiran
</Text>
</Group>
</Group>
<form
onSubmit={form.onSubmit((values) => {
handleSubmit(values);
})}
>
<Stack gap="md">
{/* Section: Data Bayi */}
<FormSection
title="Data Bayi"
subtitle="Informasi lengkap tentang bayi yang lahir"
icon={<IconUser size={20} />}
>
<Grid>
<Grid.Col span={6}>
<FormField
id="bayi-namaLengkap"
label="Nama Lengkap"
description="Nama lengkap bayi yang baru lahir (jika sudah ditentukan)."
required
>
<TextInput
id="bayi-namaLengkap"
placeholder="Contoh: Putu Gede"
{...form.getInputProps("dataBayi.namaLengkap")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={6}>
<FormField
id="bayi-jenisKelamin"
label="Jenis Kelamin"
description="Pilih jenis kelamin bayi."
required
>
<Select
id="bayi-jenisKelamin"
placeholder="Pilih"
data={["Laki-laki", "Perempuan"]}
{...form.getInputProps("dataBayi.jenisKelamin")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={6}>
<FormField
label="Tempat Lahir"
id="bayi-tempatLahir"
description="Nama desa/kelurahan/kecamatan/kabupaten/kota."
>
<TextInput
id="bayi-tempatLahir"
placeholder="Contoh: RS X, Kecamatan Y"
{...form.getInputProps("dataBayi.tempatLahir")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={3}>
<FormField
id="bayi-tanggalLahir"
label="Tanggal Lahir"
description="Tanggal lahir bayi."
required
>
<DatePicker
id="bayi-tanggalLahir"
value={form.values.dataBayi.tanggalLahir}
onChange={(d) =>
form.setFieldValue("dataBayi.tanggalLahir", d as any)
}
/>
</FormField>
</Grid.Col>
<Grid.Col span={3}>
<FormField
id="bayi-jamLahir"
label="Jam Lahir"
description="Jam lahir bayi."
>
<TimeInput
id="bayi-jamLahir"
placeholder="HH:MM"
value={form.values.dataBayi.jamLahir as any}
onChange={(d) =>
form.setFieldValue("dataBayi.jamLahir", d as any)
}
/>
</FormField>
</Grid.Col>
<Grid.Col span={3}>
<FormField
id="bayi-beratBadan"
label="Berat Badan (kg)"
description="Berat dalam kilogram."
>
<NumberInput
id="bayi-beratBadan"
placeholder="3.2"
step={0.01}
min={0}
{...form.getInputProps("dataBayi.beratBadan")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={3}>
<FormField
id="bayi-panjangBadan"
label="Panjang Badan (cm)"
description="Panjang dalam sentimeter."
>
<NumberInput
id="bayi-panjangBadan"
placeholder="50"
step={0.5}
min={0}
{...form.getInputProps("dataBayi.panjangBadan")}
/>
</FormField>
</Grid.Col>
</Grid>
</FormSection>
{/* Section: Data Ibu */}
<FormSection
title="Data Ibu"
subtitle="Data identitas ibu kandung"
icon={<IconUser size={20} />}
>
<Grid>
<Grid.Col span={6}>
<FormField
id="ibu-nama"
label="Nama Lengkap Ibu"
required
description="Nama lengkap ibu kandung bayi."
>
<TextInput
id="ibu-nama"
placeholder="Nama lengkap"
{...form.getInputProps("dataIbu.namaLengkap")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={6}>
<FormField
id="ibu-nik"
label="NIK Ibu"
required
description="Nomor Induk Kependudukan 16 digit."
>
<TextInput
id="ibu-nik"
placeholder="16 digit NIK"
{...form.getInputProps("dataIbu.nik")}
inputMode="numeric"
aria-label="NIK Ibu"
/>
</FormField>
</Grid.Col>
<Grid.Col span={6}>
<FormField
id="ibu-ttl"
label="Tempat & Tanggal Lahir Ibu"
description="Format: Kota, DD/MM/YYYY (bebas text)."
>
<TextInput
id="ibu-ttl"
placeholder="Contoh: Denpasar, 01/01/1990"
{...form.getInputProps("dataIbu.tempatTanggalLahir")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={6}>
<FormField
id="ibu-pekerjaan"
label="Pekerjaan Ibu"
description="Pekerjaan ibu."
>
<TextInput
id="ibu-pekerjaan"
placeholder="Contoh: Ibu Rumah Tangga"
{...form.getInputProps("dataIbu.pekerjaan")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={12}>
<FormField
id="ibu-alamat"
label="Alamat Ibu"
description="Alamat lengkap sesuai KTP."
>
<Textarea
id="ibu-alamat"
placeholder="Alamat lengkap"
autosize
minRows={2}
{...form.getInputProps("dataIbu.alamat")}
/>
</FormField>
</Grid.Col>
</Grid>
</FormSection>
{/* Section: Data Ayah */}
<FormSection
title="Data Ayah"
subtitle="Data identitas ayah kandung (jika tersedia)"
icon={<IconUser size={20} />}
>
<Grid>
<Grid.Col span={6}>
<FormField
id="ayah-nama"
label="Nama Lengkap Ayah"
description="Nama lengkap ayah kandung bayi."
>
<TextInput
id="ayah-nama"
placeholder="Nama lengkap"
{...form.getInputProps("dataAyah.namaLengkap")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={6}>
<FormField
id="ayah-nik"
label="NIK Ayah"
description="Nomor Induk Kependudukan (16 digit)."
>
<TextInput
id="ayah-nik"
placeholder="16 digit NIK"
{...form.getInputProps("dataAyah.nik")}
inputMode="numeric"
/>
</FormField>
</Grid.Col>
<Grid.Col span={6}>
<FormField
id="ayah-ttl"
label="Tempat & Tanggal Lahir Ayah"
description="Format: Kota, DD/MM/YYYY (bebas text)."
>
<TextInput
id="ayah-ttl"
placeholder="Contoh: Badung, 15/05/1988"
{...form.getInputProps("dataAyah.tempatTanggalLahir")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={6}>
<FormField
id="ayah-pekerjaan"
label="Pekerjaan Ayah"
description="Pekerjaan ayah."
>
<TextInput
id="ayah-pekerjaan"
placeholder="Contoh: Petani"
{...form.getInputProps("dataAyah.pekerjaan")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={12}>
<FormField
id="ayah-alamat"
label="Alamat Ayah"
description="Alamat lengkap ayah."
>
<Textarea
id="ayah-alamat"
placeholder="Alamat lengkap"
autosize
minRows={2}
{...form.getInputProps("dataAyah.alamat")}
/>
</FormField>
</Grid.Col>
</Grid>
</FormSection>
{/* Section: Data Pelapor */}
<FormSection
title="Data Pelapor"
subtitle="Orang yang melaporkan kelahiran"
icon={<IconUser size={20} />}
>
<Grid>
<Grid.Col span={6}>
<FormField
id="pelapor-nama"
label="Nama Pelapor"
required
description="Bisa ayah/ibu/kerabat."
>
<TextInput
id="pelapor-nama"
placeholder="Nama pelapor"
{...form.getInputProps("dataPelapor.namaLengkap")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={6}>
<FormField
id="pelapor-nik"
label="NIK Pelapor"
required
description="NIK 16 digit."
>
<TextInput
id="pelapor-nik"
placeholder="16 digit NIK"
{...form.getInputProps("dataPelapor.nik")}
inputMode="numeric"
/>
</FormField>
</Grid.Col>
<Grid.Col span={6}>
<FormField
id="pelapor-hubungan"
label="Hubungan dengan Bayi"
description="Contoh: Ayah, Ibu, Kakek, Nenek, dll."
>
<TextInput
id="pelapor-hubungan"
placeholder="Contoh: Ayah"
{...form.getInputProps(
"dataPelapor.hubunganDenganBayi",
)}
/>
</FormField>
</Grid.Col>
<Grid.Col span={6}>
<FormField
id="pelapor-alamat"
label="Alamat Pelapor"
description="Alamat lengkap pelapor."
>
<Textarea
id="pelapor-alamat"
autosize
minRows={2}
{...form.getInputProps("dataPelapor.alamat")}
/>
</FormField>
</Grid.Col>
</Grid>
</FormSection>
{/* Section: Saksi (array) */}
<FormSection
title="Saksi"
subtitle="Daftar saksi yang menyaksikan proses kelahiran"
icon={<IconUser size={20} />}
>
<Stack gap="sm">
{form.values.saksi.map((s, idx) => (
<Card key={idx} radius="md" p="sm" withBorder>
<Grid align="center">
<Grid.Col span={10}>
<Grid>
<Grid.Col span={6}>
<FormField
id={`saksi-${idx}-nama`}
label={`Saksi ${idx + 1} - Nama Lengkap`}
description="Nama lengkap saksi."
>
<TextInput
id={`saksi-${idx}-nama`}
placeholder="Nama lengkap"
value={form.values.saksi[idx]?.namaLengkap}
onChange={(e) => {
const arr = [...form.values.saksi] as any;
arr[idx] = {
...arr[idx],
namaLengkap: e.target.value,
};
form.setFieldValue("saksi", arr);
}}
/>
</FormField>
</Grid.Col>
<Grid.Col span={6}>
<FormField
id={`saksi-${idx}-nik`}
label="NIK"
description="NIK 16 digit (opsional jika tidak punya)."
>
<TextInput
id={`saksi-${idx}-nik`}
placeholder="16 digit NIK"
value={form.values.saksi[idx]?.nik}
onChange={(e) => {
const arr = [...form.values.saksi] as any;
arr[idx] = {
...arr[idx],
nik: e.target.value,
};
form.setFieldValue("saksi", arr);
}}
/>
</FormField>
</Grid.Col>
<Grid.Col span={12}>
<FormField
id={`saksi-${idx}-alamat`}
label="Alamat Saksi"
description="Alamat lengkap saksi."
>
<Textarea
id={`saksi-${idx}-alamat`}
autosize
minRows={2}
value={form.values.saksi[idx]?.alamat}
onChange={(e) => {
const arr = [...form.values.saksi] as any;
arr[idx] = {
...arr[idx],
alamat: e.target.value,
};
form.setFieldValue("saksi", arr);
}}
/>
</FormField>
</Grid.Col>
</Grid>
</Grid.Col>
<Grid.Col span={2}>
<Group justify="right">
<ActionIcon
color="red"
variant="subtle"
onClick={() => removeSaksi(idx)}
aria-label={`Hapus saksi ${idx + 1}`}
>
<IconTrash />
</ActionIcon>
</Group>
</Grid.Col>
</Grid>
</Card>
))}
<Group justify="left">
<Button
leftSection={<IconPlus />}
variant="outline"
onClick={addSaksi}
aria-label="Tambah saksi"
>
Tambah Saksi
</Button>
</Group>
{/* Display saksi-level error if exists */}
{form.errors.saksi ? (
<Text color="red" size="sm">
{form.errors.saksi as unknown as string}
</Text>
) : null}
</Stack>
</FormSection>
{/* Additional notes, pelaporan, pengesahan */}
<Grid>
<Grid.Col span={6}>
<FormField
id="keteranganTambahan"
label="Keterangan Tambahan"
description="Catatan atau keterangan lain yang perlu dicantumkan."
>
<Textarea
id="keteranganTambahan"
autosize
minRows={3}
{...form.getInputProps("keteranganTambahan")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={6}>
<FormField
id="tanggalPelaporan"
label="Tanggal Pelaporan"
description="Tanggal saat pelaporan surat keterangan kelahiran."
required
>
<DatePicker
id="tanggalPelaporan"
value={form.values.tanggalPelaporan}
onChange={(d) =>
form.setFieldValue("tanggalPelaporan", d as any)
}
/>
</FormField>
</Grid.Col>
</Grid>
<FormSection
title="Pengesahan"
subtitle="Pihak-pihak yang menandatangani/pengesahan"
icon={<IconUser size={20} />}
>
<Grid>
<Grid.Col span={4}>
<FormField
id="pengesahan-kepala"
label="Kepala Desa / Lurah"
description="Nama Kepala Desa atau Lurah yang mengesahkan."
>
<TextInput
id="pengesahan-kepala"
placeholder="Nama"
{...form.getInputProps("pengesahan.kepalaDesaLurah")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={4}>
<FormField
id="pengesahan-camat"
label="Camat"
description="Nama Camat yang mengesahkan."
>
<TextInput
id="pengesahan-camat"
placeholder="Nama"
{...form.getInputProps("pengesahan.camat")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={4}>
<FormField
id="pengesahan-petugas"
label="Petugas Registrasi"
description="Nama petugas pencatat sipil."
>
<TextInput
id="pengesahan-petugas"
placeholder="Nama"
{...form.getInputProps("pengesahan.petugasRegistrasi")}
/>
</FormField>
</Grid.Col>
</Grid>
</FormSection>
<Divider />
{/* Submit / Reset */}
<Group justify="right" gap="sm">
<Button
leftSection={<IconX />}
variant="outline"
onClick={handleReset}
aria-label="Reset form"
type="button"
>
Reset
</Button>
<Button
leftSection={<IconCheck />}
type="submit"
aria-label="Submit form"
>
Submit
</Button>
</Group>
</Stack>
</form>
</Stack>
</Box>
</Container>
);
}

View File

@@ -0,0 +1,649 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import React, { useState } from "react";
import {
Card,
Stack,
Group,
Text,
TextInput,
Textarea,
Select,
MultiSelect,
NumberInput,
Switch,
Button,
FileButton,
Divider,
Accordion,
Grid,
ActionIcon,
Container,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import {
IconMapPin,
IconUpload,
IconUser,
IconPhoto,
IconVideo,
IconFileText,
IconTrash,
IconPlus,
} from "@tabler/icons-react";
// ---------------------------
// Types generated from provided schema
// ---------------------------
type ReportType =
| "Sampah Liar"
| "Sampah Terbakar"
| "Penimbunan Ilegal"
| "Lainnya";
type StatusType =
| "Pending"
| "Terverifikasi"
| "Dalam Penanganan"
| "Selesai"
| "Ditolak";
type PriorityType = "Rendah" | "Sedang" | "Tinggi" | "Darurat";
type Reporter = {
isAnonymous: boolean;
name?: string | null;
phone?: string | null;
email?: string | null;
userId?: string | null;
};
type Location = {
address?: string | null;
village?: string | null;
subDistrict?: string | null;
city?: string | null;
province?: string | null;
postalCode?: string | null;
latitude?: number | null;
longitude?: number | null;
placeType?: string | null;
};
type WasteDetails = {
wasteTypes: string[];
estimatedVolume?: string | null;
hazardous?: boolean;
detailB3?: string | null;
};
type Evidence = {
photos: string[]; // urls or base64
videos: string[]; // urls or base64
attachments: string[]; // other files
};
type ReportFormValues = {
reportId?: string | null;
reportType?: ReportType | null;
status?: StatusType | null;
priority?: PriorityType | null;
reporter: Reporter;
location: Location;
wasteDetails: WasteDetails;
evidence: Evidence;
notes?: string | null;
};
// ---------------------------
// Helper: file -> base64
// ---------------------------
async function fileToBase64(file: File | null): Promise<string | null> {
if (!file) return null;
return await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = (err) => reject(err);
reader.readAsDataURL(file);
});
}
// ---------------------------
// Main component
// ---------------------------
export default function FormLaporanSampah() {
const form = useForm<ReportFormValues>({
initialValues: {
reportId: "",
reportType: null,
status: "Pending",
priority: "Sedang",
reporter: {
isAnonymous: false,
name: "",
phone: "",
email: "",
userId: "",
},
location: {
address: "",
village: "",
subDistrict: "",
city: "",
province: "",
postalCode: "",
latitude: null,
longitude: null,
placeType: "",
},
wasteDetails: {
wasteTypes: [],
estimatedVolume: "",
hazardous: false,
detailB3: "",
},
evidence: {
photos: [],
videos: [],
attachments: [],
},
notes: "",
},
validate: {
// basic validations
reportType: (v) => (!v ? "Pilih tipe laporan" : null),
status: (v) => (!v ? "Status diperlukan" : null),
// location requires at least address OR lat/lng
// We'll validate in onSubmit for combined rules
},
});
// small UI helpers
const [photoUploadName, setPhotoUploadName] = useState<string | null>(null);
const [videoUploadName, setVideoUploadName] = useState<string | null>(null);
const [attachmentName, setAttachmentName] = useState<string | null>(null);
// add photo/video/attachment entry from URL or uploaded file
const addPhotoUrl = (url: string) => {
if (!url) return;
const arr = [...form.values.evidence.photos, url];
form.setFieldValue("evidence", { ...form.values.evidence, photos: arr });
};
const addVideoUrl = (url: string) => {
if (!url) return;
const arr = [...form.values.evidence.videos, url];
form.setFieldValue("evidence", { ...form.values.evidence, videos: arr });
};
const addAttachmentUrl = (url: string) => {
if (!url) return;
const arr = [...form.values.evidence.attachments, url];
form.setFieldValue("evidence", {
...form.values.evidence,
attachments: arr,
});
};
// submit handler
const handleSubmit = async (values: ReportFormValues) => {
// composite validations
const hasAddress = Boolean(
values.location.address && values.location.address.trim(),
);
const hasCoords =
typeof values.location.latitude === "number" &&
typeof values.location.longitude === "number";
if (!hasAddress && !hasCoords) {
alert("Mohon isi alamat atau koordinat (latitude & longitude).");
return;
}
// if hazardous true, ensure detailB3 exists
if (values.wasteDetails.hazardous && !values.wasteDetails.detailB3) {
alert("Jika terdapat sampah berbahaya, mohon isi rincian B3.");
return;
}
// if reporter anonymous, clear personal fields
const payload = { ...values };
if (payload.reporter.isAnonymous) {
payload.reporter = { isAnonymous: true } as Reporter;
}
// TODO: send to API — for now console
console.log("Submitting report:", payload);
alert("Laporan berhasil disubmit (demo). Cek console untuk payload.");
form.reset();
};
return (
<Container size="md" w="100%">
<Card shadow="sm" radius="md" p="xl">
<Stack gap="md">
<Group justify="apart">
<div>
<Text fw={700} size="lg">
Form Laporan Sampah & Lingkungan
</Text>
<Text size="sm" c="dimmed">
Gunakan formulir ini untuk melaporkan masalah sampah/lingkungan.
Sertakan bukti foto/video bila memungkinkan.
</Text>
</div>
</Group>
<form
onSubmit={form.onSubmit((values) => {
handleSubmit(values);
})}
>
<Stack gap="lg">
<Grid>
<Grid.Col span={4}>
<Select
label="Tipe Laporan"
placeholder="Pilih tipe"
data={[
"Sampah Liar",
"Sampah Terbakar",
"Penimbunan Ilegal",
"Lainnya",
]}
{...form.getInputProps("reportType")}
/>
</Grid.Col>
<Grid.Col span={4}>
<Select
label="Status"
data={[
"Pending",
"Terverifikasi",
"Dalam Penanganan",
"Selesai",
"Ditolak",
]}
{...form.getInputProps("status")}
/>
</Grid.Col>
<Grid.Col span={4}>
<Select
label="Prioritas"
data={["Rendah", "Sedang", "Tinggi", "Darurat"]}
{...form.getInputProps("priority")}
/>
</Grid.Col>
</Grid>
<Accordion variant="separated" defaultValue="reporter">
<Accordion.Item value="reporter">
<Accordion.Control icon={<IconUser size={16} />}>
Informasi Pelapor
</Accordion.Control>
<Accordion.Panel>
<Group gap="md" align="center">
<Switch
label="Laporkan sebagai anonim"
{...form.getInputProps("reporter.isAnonymous", {
type: "checkbox",
})}
/>
</Group>
{!form.values.reporter.isAnonymous && (
<Grid mt="sm">
<Grid.Col span={6}>
<TextInput
label="Nama"
placeholder="Nama pelapor"
{...form.getInputProps("reporter.name")}
></TextInput>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label="Telepon"
placeholder="08xx..."
{...form.getInputProps("reporter.phone")}
></TextInput>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label="Email"
placeholder="email@contoh.com"
{...form.getInputProps("reporter.email")}
></TextInput>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label="User ID (jika terdaftar)"
placeholder="user-uuid"
{...form.getInputProps("reporter.userId")}
></TextInput>
</Grid.Col>
</Grid>
)}
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="location">
<Accordion.Control icon={<IconMapPin size={16} />}>
Lokasi Kejadian
</Accordion.Control>
<Accordion.Panel>
<Grid>
<Grid.Col span={12}>
<Textarea
label="Alamat deskriptif"
placeholder="Jalan, RT/RW, desa/kelurahan"
{...form.getInputProps("location.address")}
/>
</Grid.Col>
<Grid.Col span={4}>
<TextInput
label="Kelurahan/Desa"
{...form.getInputProps("location.village")}
/>
</Grid.Col>
<Grid.Col span={4}>
<TextInput
label="Kecamatan"
{...form.getInputProps("location.subDistrict")}
/>
</Grid.Col>
<Grid.Col span={4}>
<TextInput
label="Kabupaten/Kota"
{...form.getInputProps("location.city")}
/>
</Grid.Col>
<Grid.Col span={4}>
<TextInput
label="Provinsi"
{...form.getInputProps("location.province")}
/>
</Grid.Col>
<Grid.Col span={4}>
<TextInput
label="Kode Pos"
{...form.getInputProps("location.postalCode")}
/>
</Grid.Col>
<Grid.Col span={4}>
<Select
label="Jenis Lokasi"
data={[
"Pinggir Jalan",
"Sungai/Drainase",
"Lapangan",
"Hutan",
"Permukiman",
"Lainnya",
]}
{...form.getInputProps("location.placeType")}
/>
</Grid.Col>
<Grid.Col span={6}>
<NumberInput
label="Latitude"
placeholder="-6.200000"
style={{
precision: 6,
}}
{...form.getInputProps("location.latitude")}
/>
</Grid.Col>
<Grid.Col span={6}>
<NumberInput
label="Longitude"
placeholder="106.816666"
style={{
precision: 6,
}}
{...form.getInputProps("location.longitude")}
/>
</Grid.Col>
</Grid>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="wasteDetails">
<Accordion.Control icon={<IconFileText size={16} />}>
Rincian Sampah
</Accordion.Control>
<Accordion.Panel>
<Grid>
<Grid.Col span={12}>
<MultiSelect
label="Jenis Sampah"
placeholder="Pilih atau ketik jenis sampah"
data={[
"Plastik",
"Organik",
"Elektronik",
"Konstruksi",
"Ban",
"Kertas",
"Kaca",
"Lainnya",
]}
searchable
{...form.getInputProps("wasteDetails.wasteTypes")}
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label="Estimasi Volume"
placeholder="Mis. '2 karung', '3 m3'"
{...form.getInputProps(
"wasteDetails.estimatedVolume",
)}
/>
</Grid.Col>
<Grid.Col span={6}>
<Group justify="left" gap="sm" align="center">
<Switch
label="Mengandung B3 (Berbahaya)"
{...form.getInputProps("wasteDetails.hazardous", {
type: "checkbox",
})}
/>
</Group>
</Grid.Col>
{form.values.wasteDetails.hazardous && (
<Grid.Col span={12}>
<Textarea
label="Rincian B3"
placeholder="Jelaskan bahan berbahaya"
{...form.getInputProps("wasteDetails.detailB3")}
/>
</Grid.Col>
)}
</Grid>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="evidence">
<Accordion.Control icon={<IconPhoto size={16} />}>
Bukti & Lampiran
</Accordion.Control>
<Accordion.Panel>
<Stack gap="sm">
<Text size="sm" c="dimmed">
Unggah foto, video, atau lampiran lain. Anda bisa
mengunggah file (disimpan sebagai base64) atau
menempelkan URL.
</Text>
<Grid>
<Grid.Col span={6}>
<Group>
<FileButton
onChange={async (file) => {
if (!file) return;
const base64 = await fileToBase64(file);
if (!base64) return;
addPhotoUrl(base64);
setPhotoUploadName(file.name);
}}
accept="image/*"
>
{(props) => (
<Button
leftSection={<IconUpload size={16} />}
{...props}
>
Upload Foto
</Button>
)}
</FileButton>
<ActionIcon
onClick={() => {
// quick add placeholder example photo (smart default)
addPhotoUrl(
"https://via.placeholder.com/800x600.png?text=Foto+contoh",
);
}}
>
<IconPlus />
</ActionIcon>
</Group>
<Stack mt="sm">
{form.values.evidence.photos.map((p, idx) => (
<Group key={idx} justify="apart">
<Text
size="sm"
style={{ wordBreak: "break-all" }}
>
{p.length > 60 ? p.slice(0, 60) + "..." : p}
</Text>
<ActionIcon
color="red"
onClick={() => {
const arr =
form.values.evidence.photos.filter(
(_, i) => i !== idx,
);
form.setFieldValue("evidence", {
...form.values.evidence,
photos: arr,
});
}}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
))}
</Stack>
</Grid.Col>
<Grid.Col span={6}>
<Group>
<FileButton
onChange={async (file) => {
if (!file) return;
const base64 = await fileToBase64(file);
if (!base64) return;
addAttachmentUrl(base64);
setAttachmentName(file.name);
}}
accept="*/*"
>
{(props) => (
<Button
leftSection={<IconUpload size={16} />}
{...props}
>
Upload Lampiran
</Button>
)}
</FileButton>
<FileButton
onChange={async (file) => {
if (!file) return;
const base64 = await fileToBase64(file);
if (!base64) return;
addVideoUrl(base64);
setVideoUploadName(file.name);
}}
accept="video/*"
>
{(props) => (
<Button
leftSection={<IconUpload size={16} />}
{...props}
>
Upload Video
</Button>
)}
</FileButton>
</Group>
<Stack mt="sm">
{form.values.evidence.videos.map((v, idx) => (
<Group key={idx} justify="apart">
<Text
size="sm"
style={{ wordBreak: "break-all" }}
>
{v.length > 60 ? v.slice(0, 60) + "..." : v}
</Text>
<ActionIcon
color="red"
onClick={() => {
const arr =
form.values.evidence.videos.filter(
(_, i) => i !== idx,
);
form.setFieldValue("evidence", {
...form.values.evidence,
videos: arr,
});
}}
>
<IconTrash size={16} />
</ActionIcon>
</Group>
))}
</Stack>
</Grid.Col>
</Grid>
</Stack>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Divider />
<Textarea
label="Catatan Tambahan"
placeholder="Informasi lain yang relevan"
{...form.getInputProps("notes")}
/>
<Group justify="right" gap="sm">
<Button variant="default" onClick={() => form.reset()}>
Reset
</Button>
<Button type="submit">Kirim Laporan</Button>
</Group>
<Text size="xs" c="dimmed">
Tip: Sertakan minimal 1 foto jika memungkinkan untuk mempercepat
verifikasi.
</Text>
</Stack>
</form>
</Stack>
</Card>
</Container>
);
}

View File

@@ -0,0 +1,549 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// pages/surat-keterangan-belum-kawin/page.tsx
import { useState } from "react";
import {
Container,
Card,
Stack,
Title,
Text,
Divider,
Group,
SimpleGrid,
TextInput,
Textarea,
Select,
RadioGroup,
Radio,
FileInput,
Button,
Tooltip,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { DatePicker } from "@mantine/dates";
import { showNotification } from "@mantine/notifications";
import {
IconFileText,
IconUser,
IconId,
IconMapPin,
IconCalendar,
IconBuildingStore,
IconBadge,
IconUpload,
IconCheck,
IconX,
IconAlertCircle,
} from "@tabler/icons-react";
import { useNavigate } from "react-router-dom";
export default function FormSuratKeteranganBelumKawin() {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [ttdPreview, setTtdPreview] = useState<string | null>(null);
const [stempelPreview, setStempelPreview] = useState<string | null>(null);
// Mantine form state + validation rules
const form = useForm({
initialValues: {
instansiPenerbit: {
kabupatenKota: "",
kecamatan: "",
desaKelurahan: "",
nomorSurat: "",
},
dataPemohon: {
namaLengkap: "",
nik: "",
tempatTanggalLahir: "",
jenisKelamin: "Laki-laki",
agama: "Islam",
pekerjaan: "",
alamat: "",
statusPerkawinan: "Belum Kawin",
},
isiSurat: {
pernyataan:
"Yang bertanda tangan di bawah ini menerangkan bahwa yang bersangkutan benar-benar belum pernah menikah sampai dengan tanggal surat ini.",
tujuan: "",
},
tanggalPenerbitan: null,
pengesahan: {
kepalaDesaLurah: "",
jabatan: "",
tandaTangan: null,
stempel: null,
},
},
validate: {
instansiPenerbit: {
kabupatenKota: (value: any) =>
value.trim().length > 0 ? null : "Kabupaten/Kota wajib diisi.",
kecamatan: (value: any) =>
value.trim().length > 0 ? null : "Kecamatan wajib diisi.",
desaKelurahan: (value: any) =>
value.trim().length > 0 ? null : "Desa/Kelurahan wajib diisi.",
nomorSurat: (value: any) =>
value.trim().length > 0 ? null : "Nomor surat wajib diisi.",
},
dataPemohon: {
namaLengkap: (value: any) =>
value.trim() ? null : "Nama lengkap wajib diisi.",
nik: (value: any) =>
/^\d{16}$/.test(value)
? null
: "NIK harus 16 digit angka tanpa spasi.",
tempatTanggalLahir: (value: any) =>
value.trim().length > 0
? null
: "Tempat dan tanggal lahir wajib diisi.",
pekerjaan: (value: any) =>
value.trim() ? null : "Pekerjaan wajib diisi.",
alamat: (value: any) => (value.trim() ? null : "Alamat wajib diisi."),
},
isiSurat: {
pernyataan: (value: any) =>
value.trim().length > 20
? null
: "Pernyataan harus singkat tapi jelas (min 20 karakter).",
tujuan: (value: any) =>
value.trim() ? null : "Sebutkan tujuan penerbitan surat.",
},
tanggalPenerbitan: (value: any) =>
value ? null : "Tanggal penerbitan wajib dipilih.",
pengesahan: {
kepalaDesaLurah: (value: any) =>
value.trim() ? null : "Nama kepala desa/lurah wajib diisi.",
jabatan: (value: any) => (value.trim() ? null : "Jabatan wajib diisi."),
},
},
});
// Handle file inputs and provide previews
const handleTtdChange = (file: File | null) => {
form.setFieldValue("pengesahan.tandaTangan", file as any);
if (file) {
const url = URL.createObjectURL(file);
setTtdPreview(url);
} else {
setTtdPreview(null);
}
};
const handleStempelChange = (file: File | null) => {
form.setFieldValue("pengesahan.stempel", file as any);
if (file) {
const url = URL.createObjectURL(file);
setStempelPreview(url);
} else {
setStempelPreview(null);
}
};
// Simulate submit (replace with real API call)
const handleSubmit = async (values: any) => {
setLoading(true);
try {
// Example: prepare form data for file upload
const payload = new FormData();
payload.append(
"data",
JSON.stringify({
...values,
tanggalPenerbitan:
values.tanggalPenerbitan?.toISOString().slice(0, 10) ?? null,
}),
);
if (values.pengesahan.tandaTangan) {
payload.append("tandaTangan", values.pengesahan.tandaTangan);
}
if (values.pengesahan.stempel) {
payload.append("stempel", values.pengesahan.stempel);
}
// Replace the URL below with your API endpoint
const res = await fetch("/api/surat/belum-kawin", {
method: "POST",
body: payload,
});
if (!res.ok) throw new Error("Gagal mengirim data ke server.");
showNotification({
title: "Sukses",
message: "Surat berhasil diajukan / disimpan.",
color: "green",
icon: <IconCheck />,
});
// optional: navigate back or clear form
navigate("/darmasaba");
form.reset();
setTtdPreview(null);
setStempelPreview(null);
} catch (err: any) {
showNotification({
title: "Terjadi Kesalahan",
message: err?.message || "Gagal mengirim data.",
color: "red",
icon: <IconX />,
});
} finally {
setLoading(false);
}
};
return (
<Container size="md" py="xl">
<Stack gap="xl">
<Group justify="apart" align="center">
<Title order={2}>
<Group gap="xs">
<IconFileText />
<span>Surat Keterangan Belum Kawin</span>
</Group>
</Title>
</Group>
<Text color="dimmed">
Blangko resmi untuk menyatakan bahwa seseorang belum pernah menikah.
Lengkapi semua data lalu tekan <strong>Ajukan</strong>.
</Text>
<Card withBorder radius="md" p="lg" aria-labelledby="instansi-heading">
<Stack gap="sm">
<Group justify="apart" align="center">
<Title order={4} id="instansi-heading">
<Group gap="xs">
<IconBuildingStore />
Instansi Penerbit
</Group>
</Title>
</Group>
<Divider />
<SimpleGrid cols={2} spacing="md">
<TextInput
required
label="Kabupaten / Kota"
placeholder="Contoh: Kabupaten Badung"
description="Nama Kabupaten/Kota penerbit surat"
leftSection={<IconBadge />}
{...form.getInputProps("instansiPenerbit.kabupatenKota")}
aria-label="Kabupaten atau Kota"
/>
<TextInput
required
label="Kecamatan"
placeholder="Contoh: Kuta"
description="Nama Kecamatan penerbit surat"
leftSection={<IconMapPin />}
{...form.getInputProps("instansiPenerbit.kecamatan")}
aria-label="Kecamatan"
/>
<TextInput
required
label="Desa / Kelurahan"
placeholder="Contoh: Desa XYZ"
description="Nama Desa atau Kelurahan penerbit"
{...form.getInputProps("instansiPenerbit.desaKelurahan")}
aria-label="Desa atau Kelurahan"
/>
<TextInput
required
label="Nomor Surat"
placeholder="Format: 123/ABC/2025"
description="Nomor surat sesuai register desa/kelurahan"
{...form.getInputProps("instansiPenerbit.nomorSurat")}
aria-label="Nomor surat"
/>
</SimpleGrid>
</Stack>
</Card>
<Card withBorder radius="md" p="lg" aria-labelledby="pemohon-heading">
<Stack gap="sm">
<Group justify="apart" align="center">
<Title order={4} id="pemohon-heading">
<Group gap="xs">
<IconUser />
Data Pemohon
</Group>
</Title>
</Group>
<Divider />
<SimpleGrid cols={2} spacing="md">
<TextInput
required
label="Nama Lengkap"
placeholder="Nama sesuai KTP"
description="Masukkan nama lengkap seperti tertera di KTP"
leftSection={<IconUser />}
{...form.getInputProps("dataPemohon.namaLengkap")}
/>
<TextInput
required
label="NIK (16 digit)"
placeholder="Contoh: 3272011201010001"
description="Nomor Induk Kependudukan, 16 digit tanpa spasi"
leftSection={<IconId />}
{...form.getInputProps("dataPemohon.nik")}
inputMode="numeric"
maxLength={16}
/>
<TextInput
required
label="Tempat, Tanggal Lahir"
placeholder="Contoh: Denpasar, 01 Januari 1990"
description="Masukkan tempat dan tanggal lahir"
{...form.getInputProps("dataPemohon.tempatTanggalLahir")}
/>
<Select
label="Agama"
placeholder="Pilih agama"
data={[
"Islam",
"Kristen",
"Katolik",
"Hindu",
"Buddha",
"Konghucu",
"Lainnya",
]}
description="Agama sesuai identitas"
{...form.getInputProps("dataPemohon.agama")}
/>
<RadioGroup
label="Jenis Kelamin"
{...form.getInputProps("dataPemohon.jenisKelamin")}
>
<Radio value="Laki-laki" label="Laki-laki" />
<Radio value="Perempuan" label="Perempuan" />
</RadioGroup>
<TextInput
label="Pekerjaan"
placeholder="Contoh: Petani / PNS / Swasta"
{...form.getInputProps("dataPemohon.pekerjaan")}
/>
<Textarea
minRows={2}
label="Alamat"
placeholder="Alamat lengkap sesuai domisili"
description="Contoh: Jl. Mawar No. 1 RT 01 RW 02"
{...form.getInputProps("dataPemohon.alamat")}
/>
<TextInput
label="Status Perkawinan"
description="Dalam surat ini nilai default harus 'Belum Kawin'"
{...form.getInputProps("dataPemohon.statusPerkawinan")}
readOnly
/>
</SimpleGrid>
</Stack>
</Card>
<Card withBorder radius="md" p="lg" aria-labelledby="isi-heading">
<Stack gap="sm">
<Group justify="apart" align="center">
<Title order={4} id="isi-heading">
<Group gap="xs">
<IconFileText />
Isi Surat
</Group>
</Title>
</Group>
<Divider />
<Textarea
label="Pernyataan"
minRows={4}
description="Teks pernyataan resmi (boleh diedit)."
placeholder="Tulis pernyataan singkat yang menyatakan pemohon belum pernah menikah..."
{...form.getInputProps("isiSurat.pernyataan")}
/>
<TextInput
label="Tujuan Penerbitan Surat"
placeholder="Contoh: Untuk syarat pernikahan / beasiswa / administrasi"
description="Jelaskan singkat tujuan pembuatan surat"
{...form.getInputProps("isiSurat.tujuan")}
/>
</Stack>
</Card>
<Card withBorder radius="md" p="lg" aria-labelledby="tanggal-heading">
<Stack gap="sm">
<Group justify="apart" align="center">
<Title order={4} id="tanggal-heading">
<Group gap="xs">
<IconCalendar />
Tanggal Penerbitan
</Group>
</Title>
</Group>
<Divider />
<DatePicker
{...form.getInputProps("tanggalPenerbitan")}
aria-label="Tanggal penerbitan surat"
/>
</Stack>
</Card>
<Card
withBorder
radius="md"
p="lg"
aria-labelledby="pengesahan-heading"
>
<Stack gap="sm">
<Group justify="apart" align="center">
<Title order={4} id="pengesahan-heading">
<Group gap="xs">
<IconBadge />
Pengesahan
</Group>
</Title>
</Group>
<Divider />
<SimpleGrid cols={2} spacing="md">
<TextInput
label="Nama Kepala Desa / Lurah"
placeholder="Nama pejabat yang menandatangani"
description="Masukkan nama pejabat yang menandatangani"
{...form.getInputProps("pengesahan.kepalaDesaLurah")}
/>
<TextInput
label="Jabatan"
placeholder="Contoh: Kepala Desa"
description="Jabatan pejabat yang mengesahkan"
{...form.getInputProps("pengesahan.jabatan")}
/>
<FileInputWrapper
label="Tanda Tangan (scan)"
placeholder="Upload file tanda tangan (jpg,png,pdf)"
accept="image/*,application/pdf"
onChange={handleTtdChange}
preview={ttdPreview}
name="pengesahan.tandaTangan"
/>
<FileInputWrapper
label="Stempel (scan)"
placeholder="Upload file stempel (jpg,png,pdf)"
accept="image/*,application/pdf"
onChange={handleStempelChange}
preview={stempelPreview}
name="pengesahan.stempel"
/>
</SimpleGrid>
</Stack>
</Card>
<Group justify="right" mt="md">
<Tooltip
label="Pastikan semua data sudah benar sebelum mengajukan"
withArrow
>
<Button
leftSection={<IconUpload />}
onClick={() =>
form.validate() && form.isValid() && handleSubmit(form.values)
}
loading={loading}
>
Ajukan
</Button>
</Tooltip>
<Button
variant="outline"
onClick={() => {
form.reset();
setTtdPreview(null);
setStempelPreview(null);
}}
disabled={loading}
leftSection={<IconAlertCircle />}
>
Reset
</Button>
</Group>
<Text size="sm" color="dimmed">
Catatan: Dokumen yang diupload hanya akan dipakai untuk verifikasi
administrasi. Pastikan file bersih dan terbaca.
</Text>
</Stack>
</Container>
);
}
/**
* Small wrapper component for file input + preview with accessible labels.
* Kept inside the same file for simplicity — extract to components/ when reusing.
*/
function FileInputWrapper({
label,
placeholder,
accept,
onChange,
preview,
name,
}: {
label: string;
placeholder?: string;
accept?: string;
onChange: (file: File | null) => void;
preview?: string | null;
name: string;
}) {
return (
<Stack gap="xs">
<Group justify="apart" align="center">
<Text fw={500}>{label}</Text>
<Tooltip label="Upload scan yang jelas (jpg/png/pdf)">
<IconFileText size={16} />
</Tooltip>
</Group>
<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>
);
}

View File

@@ -0,0 +1,677 @@
import {
Accordion,
ActionIcon,
Box,
Button,
Card,
Container,
Divider,
FileInput,
Grid,
Group,
Select,
Stack,
Switch,
Text,
TextInput,
Textarea,
Tooltip,
} from "@mantine/core";
import { DatePicker } from "@mantine/dates";
import { useForm } from "@mantine/form";
import {
IconBuildingStore,
IconCalendar,
IconCheck,
IconFileUpload,
IconInfoCircle,
IconMapPin,
IconShieldCheck,
IconUser,
IconX,
} from "@tabler/icons-react";
import React from "react";
/* ---------------------------
Types (strong typing)
--------------------------- */
type MasaBerlaku = {
mulai: Date | null;
sampai: Date | null;
};
type Organisasi = {
namaOrganisasi?: string;
jenisOrganisasi?: string;
bidangKegiatan?: string;
aktePendirian?: string;
npwp?: string;
};
type AlamatDomisili = {
alamatLengkap?: string;
rt?: string;
rw?: string;
desaKelurahan?: string;
kecamatan?: string;
kabupatenKota?: string;
provinsi?: string;
kodePos?: string;
};
type PenanggungJawab = {
namaLengkap?: string;
nik?: string;
jabatan?: string;
kontak?: string;
};
type Pengesahan = {
dikeluarkanDi?: string;
tanggalDikeluarkan?: Date | null;
lurahAtauCamat?: string;
jabatanPejabat?: string;
tandaTanganStempel?: File | null;
};
export type SkdoFormValues = {
nomorSurat?: string;
organisasi: Organisasi;
alamatDomisili: AlamatDomisili;
penanggungJawab: PenanggungJawab;
keperluan?: string;
masaBerlaku: MasaBerlaku;
pengesahan: Pengesahan;
// extra helpers (e.g. draft toggle)
isDraft?: boolean;
};
/* ---------------------------
Reusable smaller components
--------------------------- */
/** Label with an info tooltip icon */
function LabelWithInfo({
label,
info,
Icon = IconInfoCircle,
}: {
label: string;
info?: string;
Icon?: React.FC<any>;
}) {
return (
<Group gap="xs" justify="apart" align="center" style={{ width: "100%" }}>
<Text fw={600} size="sm">
{label}
</Text>
{info ? (
<Tooltip label={info} withArrow>
<ActionIcon aria-label={`${label} info`}>
<Icon size={16} />
</ActionIcon>
</Tooltip>
) : null}
</Group>
);
}
/* ---------------------------
Main Form Component
--------------------------- */
export default function FormSuratKeteranganDomisiliOrganisasi() {
// sensible defaults (smart defaults)
const form = useForm<SkdoFormValues>({
initialValues: {
nomorSurat: "",
organisasi: {
namaOrganisasi: "",
jenisOrganisasi: "",
bidangKegiatan: "",
aktePendirian: "",
npwp: "",
},
alamatDomisili: {
alamatLengkap: "",
rt: "",
rw: "",
desaKelurahan: "",
kecamatan: "",
kabupatenKota: "",
provinsi: "",
kodePos: "",
},
penanggungJawab: {
namaLengkap: "",
nik: "",
jabatan: "",
kontak: "",
},
keperluan: "",
masaBerlaku: {
mulai: null,
sampai: null,
},
pengesahan: {
dikeluarkanDi: "",
tanggalDikeluarkan: null,
lurahAtauCamat: "",
jabatanPejabat: "",
tandaTanganStempel: null,
},
isDraft: false,
},
validate: {
// nomorSurat optional, but validate length if filled
nomorSurat: (value) =>
value && value.length > 100 ? "Nomor surat terlalu panjang" : null,
organisasi: {
namaOrganisasi: (v: any) =>
!v || v.trim().length === 0 ? "Nama organisasi wajib diisi" : null,
// jenisOrganisasi optional but if "Lainnya" require additional explanation? (not in schema)
} as any,
penanggungJawab: {
namaLengkap: (v: any) =>
!v || v.trim().length === 0
? "Nama penanggung jawab wajib diisi"
: null,
nik: (v: any) => {
if (!v) return "NIK wajib diisi";
const digits = v.replace(/\D/g, "");
if (digits.length !== 16) return "NIK harus 16 digit";
return null;
},
kontak: (v: any) => {
if (!v) return "Kontak wajib diisi";
if (!/^[\d+\-\s()]{6,20}$/.test(v))
return "Masukkan nomor telepon/HP yang valid";
return null;
},
} as any,
keperluan: (v) =>
!v || v.trim().length === 0 ? "Keperluan wajib diisi" : null,
masaBerlaku: {
mulai: (v: any) => (v === null ? "Tanggal mulai wajib diisi" : null),
sampai: (v: any, values: any) => {
if (v === null) return "Tanggal sampai wajib diisi";
if (values.masaBerlaku.mulai && v < values.masaBerlaku.mulai)
return "Tanggal sampai harus setelah atau sama dengan tanggal mulai";
return null;
},
} as any,
pengesahan: {
tanggalDikeluarkan: (v: any) =>
v === null ? "Tanggal dikeluarkan wajib diisi" : null,
lurahAtauCamat: (v: any) => (!v ? "Nama pejabat wajib diisi" : null),
} as any,
},
});
/* ---------------------------
Submit & Reset handlers
--------------------------- */
const handleSubmit = (values: SkdoFormValues) => {
// In a real app: send to API, show toast, handle file upload, etc.
// Here we simply log the values (and convert File to name).
const sanitized = {
...values,
pengesahan: {
...values.pengesahan,
tandaTanganStempel: values.pengesahan.tandaTanganStempel
? (values.pengesahan.tandaTanganStempel as File).name
: null,
},
};
// Simple success UX: console + accessible focus
// Replace with notifications/toasts in production
console.log("SKDO Submitted:", sanitized);
// accessible focus to top message (not implemented here) or show UI feedback
alert("Form submitted — lihat console untuk data (demo)");
};
const handleReset = () => form.reset();
/* ---------------------------
UI Layout
--------------------------- */
return (
<Container size="md" w="100%">
<Card radius="md" p="lg" withBorder>
<Stack gap="md">
{/* Header */}
<Group justify="apart" align="center" gap="sm">
<Group align="center" gap="sm">
<IconShieldCheck size={28} />
<Box>
<Text fw={700} size="lg">
Surat Keterangan Domisili Organisasi (SKDO)
</Text>
<Text size="sm" c="dimmed">
Blangko resmi untuk permohonan pembuatan Surat Keterangan
Domisili Organisasi.
</Text>
</Box>
</Group>
<Group>
<Switch
aria-label="Save as draft"
label="Simpan sebagai draft"
checked={form.values.isDraft}
onChange={(e) =>
form.setFieldValue("isDraft", e.currentTarget.checked)
}
/>
</Group>
</Group>
<Divider />
{/* Top-level basic fields */}
<Grid>
<Grid.Col span={6}>
<Stack gap="xs">
<LabelWithInfo
label="Nomor Surat"
info="Nomor surat (diisi oleh kantor kelurahan/kecamatan jika ada)."
/>
<TextInput
placeholder="e.g. 123/SKDO/2025"
{...form.getInputProps("nomorSurat")}
aria-label="Nomor Surat"
/>
</Stack>
</Grid.Col>
<Grid.Col span={6}>
<Stack gap="xs">
<LabelWithInfo
label="Keperluan"
info="Tujuan pembuatan surat domisili."
/>
<Textarea
placeholder="Contoh: Pengajuan izin operasional / Pendaftaran NPWP / Pembukaan rekening bank"
minRows={2}
{...form.getInputProps("keperluan")}
aria-label="Keperluan"
/>
</Stack>
</Grid.Col>
</Grid>
{/* Organisasi section */}
<Accordion variant="contained" chevronPosition="right" multiple>
<Accordion.Item value="organisasi">
<Accordion.Control icon={<IconBuildingStore />}>
Data Organisasi
</Accordion.Control>
<Accordion.Panel>
<Grid>
<Grid.Col span={12}>
<Stack gap="xs">
<LabelWithInfo
label="Nama Organisasi"
info="Nama lengkap organisasi/lembaga."
Icon={IconBuildingStore}
/>
<TextInput
placeholder="Nama organisasi"
{...form.getInputProps("organisasi.namaOrganisasi")}
aria-label="Nama Organisasi"
/>
</Stack>
</Grid.Col>
<Grid.Col span={6}>
<LabelWithInfo
label="Jenis Organisasi"
info="Pilih jenis organisasi."
/>
<Select
placeholder="Pilih jenis organisasi"
data={[
"Yayasan",
"Perkumpulan",
"Lembaga Sosial",
"Organisasi Keagamaan",
"Komunitas",
"Lainnya",
]}
{...form.getInputProps("organisasi.jenisOrganisasi")}
aria-label="Jenis Organisasi"
/>
</Grid.Col>
<Grid.Col span={6}>
<LabelWithInfo
label="Bidang Kegiatan"
info="Contoh: sosial, pendidikan, lingkungan, olahraga."
/>
<TextInput
placeholder="Bidang kegiatan"
{...form.getInputProps("organisasi.bidangKegiatan")}
aria-label="Bidang Kegiatan"
/>
</Grid.Col>
<Grid.Col span={6}>
<LabelWithInfo
label="Nomor Akta Pendirian"
info="Jika ada."
/>
<TextInput
placeholder="Nomor akta (opsional)"
{...form.getInputProps("organisasi.aktePendirian")}
aria-label="Akte Pendirian"
/>
</Grid.Col>
<Grid.Col span={6}>
<LabelWithInfo label="NPWP Organisasi" info="Jika ada." />
<TextInput
placeholder="NPWP (opsional)"
{...form.getInputProps("organisasi.npwp")}
aria-label="NPWP Organisasi"
/>
</Grid.Col>
</Grid>
</Accordion.Panel>
</Accordion.Item>
{/* Alamat Domisili */}
<Accordion.Item value="alamat">
<Accordion.Control icon={<IconMapPin />}>
Alamat Domisili
</Accordion.Control>
<Accordion.Panel>
<Grid>
<Grid.Col span={12}>
<LabelWithInfo
label="Alamat Lengkap"
info="Alamat tempat organisasi berdomisili."
/>
<Textarea
minRows={2}
placeholder="Jalan, nomor gedung, blok, dsb."
{...form.getInputProps("alamatDomisili.alamatLengkap")}
aria-label="Alamat Lengkap"
/>
</Grid.Col>
<Grid.Col span={2}>
<Text fw={700} size="sm">
RT
</Text>
<TextInput
placeholder="001"
{...form.getInputProps("alamatDomisili.rt")}
aria-label="RT"
/>
</Grid.Col>
<Grid.Col span={2}>
<Text fw={700} size="sm">
RW
</Text>
<TextInput
placeholder="002"
{...form.getInputProps("alamatDomisili.rw")}
aria-label="RW"
/>
</Grid.Col>
<Grid.Col span={4}>
<LabelWithInfo label="Desa / Kelurahan" />
<TextInput
placeholder="Nama desa/kelurahan"
{...form.getInputProps("alamatDomisili.desaKelurahan")}
aria-label="Desa Kelurahan"
/>
</Grid.Col>
<Grid.Col span={4}>
<LabelWithInfo label="Kecamatan" />
<TextInput
placeholder="Nama kecamatan"
{...form.getInputProps("alamatDomisili.kecamatan")}
aria-label="Kecamatan"
/>
</Grid.Col>
<Grid.Col span={6}>
<LabelWithInfo label="Kabupaten / Kota" />
<TextInput
placeholder="Nama kabupaten/kota"
{...form.getInputProps("alamatDomisili.kabupatenKota")}
aria-label="Kabupaten Kota"
/>
</Grid.Col>
<Grid.Col span={4}>
<LabelWithInfo label="Provinsi" />
<TextInput
placeholder="Nama provinsi"
{...form.getInputProps("alamatDomisili.provinsi")}
aria-label="Provinsi"
/>
</Grid.Col>
<Grid.Col span={2}>
<LabelWithInfo label="Kode Pos" />
<TextInput
placeholder="Kode pos"
{...form.getInputProps("alamatDomisili.kodePos")}
aria-label="Kode Pos"
/>
</Grid.Col>
</Grid>
</Accordion.Panel>
</Accordion.Item>
{/* Penanggung Jawab */}
<Accordion.Item value="penanggungJawab">
<Accordion.Control icon={<IconUser />}>
Penanggung Jawab
</Accordion.Control>
<Accordion.Panel>
<Grid>
<Grid.Col span={6}>
<LabelWithInfo
label="Nama Lengkap"
info="Nama lengkap ketua/penanggung jawab organisasi."
/>
<TextInput
placeholder="Nama lengkap"
{...form.getInputProps("penanggungJawab.namaLengkap")}
aria-label="Nama Penanggung Jawab"
/>
</Grid.Col>
<Grid.Col span={6}>
<LabelWithInfo
label="NIK"
info="16 digit Nomor Induk Kependudukan"
/>
<TextInput
placeholder="16 digit NIK"
{...form.getInputProps("penanggungJawab.nik")}
aria-label="NIK"
inputMode="numeric"
/>
</Grid.Col>
<Grid.Col span={6}>
<LabelWithInfo label="Jabatan" />
<TextInput
placeholder="Ketua / Sekretaris / Direktur"
{...form.getInputProps("penanggungJawab.jabatan")}
aria-label="Jabatan"
/>
</Grid.Col>
<Grid.Col span={6}>
<LabelWithInfo
label="Kontak"
info="Nomor telepon/HP penanggung jawab"
/>
<TextInput
placeholder="+62 812-3456-7890"
{...form.getInputProps("penanggungJawab.kontak")}
aria-label="Kontak"
inputMode="tel"
/>
</Grid.Col>
</Grid>
</Accordion.Panel>
</Accordion.Item>
{/* Masa Berlaku */}
<Accordion.Item value="masa">
<Accordion.Control icon={<IconCalendar />}>
Masa Berlaku
</Accordion.Control>
<Accordion.Panel>
<Grid>
<Grid.Col span={6}>
<LabelWithInfo
label="Mulai"
info="Tanggal mulai berlaku."
Icon={IconCalendar}
/>
<DatePicker
{...form.getInputProps("masaBerlaku.mulai")}
aria-label="Tanggal Mulai"
style={{
"&::after": {
content: " *",
color: "red",
},
}}
/>
</Grid.Col>
<Grid.Col span={6}>
<LabelWithInfo
label="Sampai"
info="Tanggal berakhir berlaku."
Icon={IconCalendar}
/>
<DatePicker
{...form.getInputProps("masaBerlaku.sampai")}
aria-label="Tanggal Sampai"
style={{
"&::after": {
content: " *",
color: "red",
},
}}
/>
</Grid.Col>
</Grid>
</Accordion.Panel>
</Accordion.Item>
{/* Pengesahan */}
<Accordion.Item value="pengesahan">
<Accordion.Control icon={<IconShieldCheck />}>
Pengesahan
</Accordion.Control>
<Accordion.Panel>
<Grid>
<Grid.Col span={6}>
<LabelWithInfo label="Dikeluarkan di" />
<TextInput
placeholder="Nama kota/kabupaten"
{...form.getInputProps("pengesahan.dikeluarkanDi")}
aria-label="Dikeluarkan Di"
/>
</Grid.Col>
<Grid.Col span={6}>
<LabelWithInfo label="Tanggal Dikeluarkan" />
<DatePicker
{...form.getInputProps("pengesahan.tanggalDikeluarkan")}
aria-label="Tanggal Dikeluarkan"
style={{
"&::after": {
content: " *",
color: "red",
},
}}
/>
</Grid.Col>
<Grid.Col span={6}>
<LabelWithInfo label="Nama Pejabat (Lurah/Camat)" />
<TextInput
placeholder="Nama pejabat penandatangan"
{...form.getInputProps("pengesahan.lurahAtauCamat")}
aria-label="Nama Pejabat"
/>
</Grid.Col>
<Grid.Col span={6}>
<LabelWithInfo label="Jabatan Pejabat" />
<TextInput
placeholder="Jabatan pejabat (mis. Lurah Kelurahan Sukamaju)"
{...form.getInputProps("pengesahan.jabatanPejabat")}
aria-label="Jabatan Pejabat"
/>
</Grid.Col>
<Grid.Col span={12}>
<LabelWithInfo
label="Tanda Tangan & Stempel (scan)"
info="Upload file scan tanda tangan dan stempel resmi."
Icon={IconFileUpload}
/>
<FileInput
placeholder="Pilih file (jpg, png, pdf)"
{...form.getInputProps("pengesahan.tandaTanganStempel")}
accept="image/*,application/pdf"
leftSection={<IconFileUpload size={16} />}
aria-label="Tanda Tangan Stempel"
/>
</Grid.Col>
</Grid>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
<Divider />
{/* Action buttons */}
<Group justify="right" gap="sm">
<Button
variant="default"
onClick={handleReset}
leftSection={<IconX />}
aria-label="Reset form"
>
Reset
</Button>
<Button
onClick={() => form.onSubmit(handleSubmit)}
leftSection={<IconCheck />}
aria-label="Submit form"
>
Submit
</Button>
</Group>
{/* Minimal accessibility & developer hints */}
<Text size="xs" c="dimmed">
Tip: Gunakan Tab / Shift+Tab untuk navigasi keyboard. Semua input
memiliki label yang dapat dibaca screen reader.
</Text>
</Stack>
</Card>
</Container>
);
}

View File

@@ -0,0 +1,531 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// pages/surat-keterangan-kelakuan-baik/page.tsx
// Single-file Next.js App Router page implementing the form described by the JSON schema.
// Also contains TypeScript interfaces and some small reusable components/hooks for clarity.
import React, { useState, useMemo } from "react";
import {
Container,
Card,
Title,
Text,
Divider,
Stack,
Group,
SimpleGrid,
TextInput,
Select,
Textarea,
Button,
Badge,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { DatePicker } from "@mantine/dates";
import { showNotification } from "@mantine/notifications";
import {
IconUser,
IconMapPin,
IconCalendar,
IconFileText,
IconBuildingBank,
IconChecklist,
IconCheck,
IconX,
IconCards,
IconFile,
} from "@tabler/icons-react";
// ----------------------
// TypeScript interfaces
// ----------------------
export interface Instansi {
kabupatenKota: string;
kecamatan: string;
desaKelurahan: string;
}
export type JenisKelamin = "Laki-laki" | "Perempuan";
export type Agama =
| "Islam"
| "Kristen"
| "Katolik"
| "Hindu"
| "Buddha"
| "Konghucu"
| "Lainnya";
export type StatusPerkawinan =
| "Belum Kawin"
| "Kawin"
| "Cerai Hidup"
| "Cerai Mati";
export interface DataPemohon {
namaLengkap: string;
nik: string; // expected 16 digits
tempatTanggalLahir: string;
jenisKelamin: JenisKelamin;
agama: Agama;
statusPerkawinan: StatusPerkawinan;
pekerjaan: string;
alamat: string;
}
export interface Pengesahan {
tempatTerbit: string;
tanggalTerbit: string; // ISO date
kepalaDesaLurah: string;
jabatan: string;
tandaTanganCap: string;
}
export interface SKCKPengantarForm {
formTitle: string;
description?: string;
instansi: Instansi;
nomorSurat: string;
dataPemohon: DataPemohon;
keterangan: string;
keperluan: string;
berlakuHingga: string; // ISO date
pengesahan: Pengesahan;
}
// ----------------------
// Helper validation
// ----------------------
function isValidNIK(nik: string) {
// strict: 16 digits numeric
return /^\d{16}$/.test(nik);
}
// ----------------------
// Reusable small component
// ----------------------
function SectionCard({
title,
icon,
children,
description,
}: {
title: string;
icon?: React.ReactNode;
children: React.ReactNode;
description?: string;
}) {
return (
<Card shadow="sm" radius="md" withBorder aria-labelledby={title}>
<Group justify="apart" align="center" style={{ marginBottom: 10 }}>
<Group>
{icon}
<div>
<Title order={4} id={title} style={{ lineHeight: 1 }}>
{title}
</Title>
{description && (
<Text size="sm" color="dimmed">
{description}
</Text>
)}
</div>
</Group>
</Group>
<Divider my="sm" />
<div>{children}</div>
</Card>
);
}
// ----------------------
// Main Page Component
// ----------------------
export default function FormSuratKeteranganKelakuanBaik() {
const [submitting, setSubmitting] = useState(false);
const form = useForm<any>({
initialValues: {
formTitle: "Surat Keterangan Kelakuan Baik (Pengantar SKCK)",
description:
"Blangko resmi dari Kelurahan/Desa sebagai pengantar untuk pembuatan SKCK di Kepolisian.",
instansi: {
kabupatenKota: "",
kecamatan: "",
desaKelurahan: "",
},
nomorSurat: "",
dataPemohon: {
namaLengkap: "",
nik: "",
tempatTanggalLahir: "",
jenisKelamin: "Laki-laki",
agama: "Islam",
statusPerkawinan: "Belum Kawin",
pekerjaan: "",
alamat: "",
},
keterangan: "",
keperluan: "",
berlakuHingga: new Date().toISOString().slice(0, 10),
pengesahan: {
tempatTerbit: "",
tanggalTerbit: new Date().toISOString().slice(0, 10),
kepalaDesaLurah: "",
jabatan: "",
tandaTanganCap: "",
},
},
validate: {
// top-level validations
nomorSurat: (value) =>
value.trim().length === 0 ? "Nomor surat wajib diisi" : null,
keperluan: (value) =>
value.trim().length === 0 ? "Keperluan wajib diisi" : null,
keterangan: (value) =>
value.trim().length === 0 ? "Keterangan wajib diisi" : null,
"dataPemohon.namaLengkap": (value: string) =>
!value || value.trim().length < 3
? "Nama lengkap minimal 3 karakter"
: null,
"dataPemohon.nik": (value: string) =>
!isValidNIK(value) ? "NIK harus 16 digit angka tanpa spasi" : null,
"dataPemohon.tempatTanggalLahir": (value: string) =>
!value ? "Tempat dan tanggal lahir wajib diisi" : null,
"dataPemohon.pekerjaan": (value: string) =>
!value ? "Pekerjaan wajib diisi" : null,
"dataPemohon.alamat": (value: string) =>
!value ? "Alamat wajib diisi" : null,
"pengesahan.tempatTerbit": (value: string) =>
!value ? "Tempat terbit wajib diisi" : null,
"pengesahan.tanggalTerbit": (value: string) =>
!value ? "Tanggal terbit wajib diisi" : null,
"pengesahan.kepalaDesaLurah": (value: string) =>
!value ? "Nama penandatangan wajib diisi" : null,
},
});
const agamaOptions = useMemo(
() => [
"Islam",
"Kristen",
"Katolik",
"Hindu",
"Buddha",
"Konghucu",
"Lainnya",
],
[],
);
const statusOptions = useMemo(
() => ["Belum Kawin", "Kawin", "Cerai Hidup", "Cerai Mati"],
[],
);
async function handleSubmit() {
setSubmitting(true);
try {
// simulate API call - replace with your actual endpoint
await new Promise((r) => setTimeout(r, 800));
showNotification({
title: "Berhasil",
message: "Surat pengantar berhasil disimpan.",
icon: <IconCheck size={18} />,
color: "green",
});
// Optionally redirect or reset form
// router.push('/surat/list')
} catch (error) {
console.log(error);
showNotification({
title: "Gagal",
message: "Terjadi kesalahan saat menyimpan. Coba lagi.",
icon: <IconX size={18} />,
color: "red",
});
} finally {
setSubmitting(false);
}
}
return (
<Container size="lg" py="xl">
<Stack gap="lg">
<Group justify="apart" align="center">
<div>
<Title order={2}>
Surat Keterangan Kelakuan Baik (Pengantar SKCK)
</Title>
<Text color="dimmed">
Blangko resmi dari Kelurahan/Desa sebagai pengantar pembuatan SKCK
di Kepolisian.
</Text>
</div>
<Badge variant="outline" radius="sm">
Formulir Resmi
</Badge>
</Group>
<form
onSubmit={form.onSubmit(() => handleSubmit())}
aria-label="Formulir Pengantar SKCK"
>
<Stack gap="md">
<SimpleGrid cols={3} spacing="md">
<SectionCard
title="Identitas Instansi"
icon={<IconBuildingBank size={28} />}
description="Informasi penerbit (kelurahan/desa)"
>
<SimpleGrid cols={3} spacing="sm">
<TextInput
label="Kabupaten / Kota"
placeholder="Contoh: Badung"
leftSection={<IconMapPin size={16} />}
required
{...form.getInputProps("instansi.kabupatenKota")}
aria-label="kabupaten-kota"
/>
<TextInput
label="Kecamatan"
placeholder="Contoh: Kuta"
leftSection={<IconMapPin size={16} />}
required
{...form.getInputProps("instansi.kecamatan")}
aria-label="kecamatan"
/>
<TextInput
label="Desa / Kelurahan"
placeholder="Contoh: Tuban"
leftSection={<IconMapPin size={16} />}
required
{...form.getInputProps("instansi.desaKelurahan")}
aria-label="desa-kelurahan"
/>
</SimpleGrid>
</SectionCard>
<SectionCard
title="Nomor & Masa Berlaku"
icon={<IconFileText size={28} />}
description="Nomor registrasi surat dan tanggal masa berlaku"
>
<SimpleGrid cols={2} spacing="sm">
<TextInput
label="Nomor Surat"
placeholder="2025/KT-001/123"
leftSection={<IconFileText size={16} />}
required
{...form.getInputProps("nomorSurat")}
aria-label="nomor-surat"
/>
<DatePicker
{...form.getInputProps("berlakuHingga")}
value={
form.values.berlakuHingga
? new Date(form.values.berlakuHingga)
: null
}
onChange={(d) =>
form.setFieldValue("berlakuHingga", d as any)
}
aria-label="berlaku-hingga"
/>
</SimpleGrid>
</SectionCard>
<SectionCard
title="Data Pemohon"
icon={<IconUser size={28} />}
description="Data pemohon sesuai KTP"
>
<Stack gap="sm">
<SimpleGrid cols={2}>
<TextInput
label="Nama Lengkap"
placeholder="Nama sesuai KTP"
required
{...form.getInputProps("dataPemohon.namaLengkap")}
leftSection={<IconUser size={16} />}
aria-label="nama-lengkap"
/>
<TextInput
label="NIK (16 digit)"
placeholder="Contoh: 3574xxxxxxxxxxxx"
required
{...form.getInputProps("dataPemohon.nik")}
leftSection={<IconCards size={16} />}
aria-label="nik"
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing={"md"}>
<TextInput
label="Tempat, Tanggal Lahir"
placeholder="Contoh: Denpasar, 01 Januari 1990"
required
{...form.getInputProps("dataPemohon.tempatTanggalLahir")}
leftSection={<IconCalendar size={16} />}
aria-label="tempat-tanggal-lahir"
/>
<Select
label="Jenis Kelamin"
data={[
{ value: "Laki-laki", label: "Laki-laki" },
{ value: "Perempuan", label: "Perempuan" },
]}
{...form.getInputProps("dataPemohon.jenisKelamin")}
aria-label="jenis-kelamin"
/>
</SimpleGrid>
<SimpleGrid cols={3} spacing={"md"}>
<Select
label="Agama"
data={agamaOptions.map((a) => ({ value: a, label: a }))}
{...form.getInputProps("dataPemohon.agama")}
aria-label="agama"
/>
<Select
label="Status Perkawinan"
data={statusOptions.map((s) => ({ value: s, label: s }))}
{...form.getInputProps("dataPemohon.statusPerkawinan")}
aria-label="status-perkawinan"
/>
<TextInput
label="Pekerjaan"
placeholder="Contoh: Buruh, PNS, Wiraswasta"
{...form.getInputProps("dataPemohon.pekerjaan")}
aria-label="pekerjaan"
/>
</SimpleGrid>
<Textarea
label="Alamat Domisili"
placeholder="Alamat lengkap sesuai KTP"
autosize
minRows={2}
{...form.getInputProps("dataPemohon.alamat")}
aria-label="alamat"
/>
</Stack>
</SectionCard>
<SectionCard
title="Keterangan & Keperluan"
icon={<IconChecklist size={28} />}
description="Pernyataan resmi kelurahan/desa"
>
<Stack>
<Textarea
label="Keterangan"
placeholder="Contoh: Pemohon berkelakuan baik..."
minRows={3}
required
{...form.getInputProps("keterangan")}
leftSection={<IconFileText size={16} />}
aria-label="keterangan"
/>
<TextInput
label="Keperluan"
placeholder="Contoh: Melamar pekerjaan di PT. X"
required
{...form.getInputProps("keperluan")}
aria-label="keperluan"
/>
</Stack>
</SectionCard>
<SectionCard
title="Pengesahan & Penandatangan"
icon={<IconFile size={28} />}
description="Informasi pejabat yang menandatangani dan cap instansi"
>
<SimpleGrid cols={2} spacing={"md"}>
<TextInput
label="Tempat Terbit"
placeholder="Contoh: Kuta"
{...form.getInputProps("pengesahan.tempatTerbit")}
aria-label="tempat-terbit"
/>
<DatePicker
value={
form.values.pengesahan.tanggalTerbit
? new Date(form.values.pengesahan.tanggalTerbit)
: null
}
{...form.getInputProps("pengesahan.tanggalTerbit")}
aria-label="tanggal-terbit"
/>
</SimpleGrid>
<SimpleGrid cols={2} spacing={"md"}>
<TextInput
label="Nama Kepala Desa / Lurah"
placeholder="Nama penandatangan"
{...form.getInputProps("pengesahan.kepalaDesaLurah")}
aria-label="kepala-desa"
/>
<TextInput
label="Jabatan"
placeholder="Contoh: Kepala Desa"
{...form.getInputProps("pengesahan.jabatan")}
aria-label="jabatan"
/>
</SimpleGrid>
<Textarea
label="Tanda Tangan & Cap (keterangan)"
placeholder="Contoh: Tanda tangan basah, cap stempel instansi"
minRows={2}
{...form.getInputProps("pengesahan.tandaTanganCap")}
aria-label="tanda-tangan-cap"
/>
</SectionCard>
</SimpleGrid>
<Group justify="right" mt="md">
<Button
variant="outline"
onClick={() => form.reset()}
leftSection={<IconX size={16} />}
aria-label="reset-form"
>
Reset
</Button>
<Button
type="submit"
leftSection={<IconChecklist size={16} />}
loading={submitting}
aria-label="submit-form"
>
Simpan & Cetak
</Button>
</Group>
</Stack>
</form>
<Text size="xs" color="dimmed">
Pastikan semua data sesuai dokumen resmi. SKCK biasanya berlaku 3
bulan sejak diterbitkan.
</Text>
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,622 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
Accordion,
Button,
Card,
Container,
Divider,
Grid,
Group,
NumberInput,
Select,
Stack,
Text,
TextInput,
Textarea,
Title,
} from "@mantine/core";
import { DatePicker } from "@mantine/dates";
import { useForm } from "@mantine/form";
import {
IconBuildingStore,
IconCalendarEvent,
IconCheck,
IconCurrencyDollar,
IconFileText,
IconInfoCircle,
IconRefresh,
IconUser,
} from "@tabler/icons-react";
// -----------------------------
// Types that mirror the JSON schema
// -----------------------------
type Currency = "IDR" | string;
interface IncomeValue {
value: number;
currency: Currency;
description?: string;
}
interface Issuer {
name: string;
position: string;
company: string;
address: string;
description?: string;
}
interface Employee {
fullName: string;
nik: string;
placeOfBirth: string;
dateOfBirth?: Date | null;
position: string;
employmentStatus: string;
description?: string;
}
interface Validity {
startDate?: Date | null;
endDate?: Date | null;
description?: string;
}
interface Signatory {
name: string;
position: string;
signature: string; // could be base64, url, or a typed name
description?: string;
}
interface CertificateFormSchema {
documentType: string;
documentNumber: { value: string; description?: string };
issuer: Issuer;
employee: Employee;
incomeDetails: {
basicSalary: IncomeValue;
allowances: IncomeValue;
deductions: IncomeValue;
netIncome: IncomeValue;
};
validity: Validity;
purpose: { value: string; description?: string };
signatory: Signatory;
issueDate?: Date | null;
}
// -----------------------------
// Reusable small components
// -----------------------------
function SectionTitle({
title,
icon: Icon,
subtitle,
}: {
title: string;
icon?: any;
subtitle?: string;
}) {
return (
<Group justify="apart" style={{ width: "100%", marginBottom: 8 }}>
<Group>
{Icon && <Icon size={18} />}
<div>
<Title order={5} style={{ margin: 0 }}>
{title}
</Title>
{subtitle && (
<Text size="xs" color="dimmed">
{subtitle}
</Text>
)}
</div>
</Group>
</Group>
);
}
// Single field renderer that maps JSON-like field meta to Mantine controls
function FormField({
label,
description,
children,
leftIcon,
}: {
label: string;
description?: string;
children: React.ReactNode;
leftIcon?: React.ReactNode;
}) {
return (
<Stack gap="md" style={{ width: "100%" }}>
<Group gap="md" align="flex-start">
{leftIcon}
<Text fw={600}>{label}</Text>
</Group>
<div>{children}</div>
{description && (
<Text size="xs" color="dimmed">
{description}
</Text>
)}
</Stack>
);
}
// -----------------------------
// Main form component
// -----------------------------
export default function SuratKeteranganPenghasilan() {
// default values: smart defaults (some example data to guide user)
const form = useForm<CertificateFormSchema>({
initialValues: {
documentType: "Surat Keterangan Penghasilan",
documentNumber: {
value: "",
description:
"Nomor surat keterangan penghasilan yang dikeluarkan oleh instansi/perusahaan.",
},
issuer: {
name: "",
position: "",
company: "",
address: "",
description:
"Data pihak yang mengeluarkan surat keterangan penghasilan, biasanya HRD/atasan langsung/perusahaan.",
},
employee: {
fullName: "",
nik: "",
placeOfBirth: "",
dateOfBirth: null,
position: "",
employmentStatus: "Karyawan Tetap",
description: "Data karyawan/pegawai yang bersangkutan.",
},
incomeDetails: {
basicSalary: {
value: 0,
currency: "IDR",
description: "Gaji pokok per bulan.",
},
allowances: {
value: 0,
currency: "IDR",
description:
"Tunjangan-tunjangan tetap (transport, makan, jabatan, dll).",
},
deductions: {
value: 0,
currency: "IDR",
description: "Potongan gaji bulanan (BPJS, pajak, koperasi, dll).",
},
netIncome: {
value: 0,
currency: "IDR",
description: "Total penghasilan bersih per bulan setelah potongan.",
},
},
validity: {
startDate: null,
endDate: null,
description: "Masa berlaku surat keterangan penghasilan.",
},
purpose: {
value: "",
description:
"Tujuan diterbitkannya surat keterangan penghasilan (contoh: pengajuan KPR, kredit, beasiswa, dll).",
},
signatory: {
name: "",
position: "",
signature: "",
description:
"Pihak yang menandatangani surat resmi, biasanya pimpinan perusahaan atau pejabat berwenang.",
},
issueDate: null,
},
// lightweight validation rules
validate: {
documentNumber: (val) =>
val.value.trim().length === 0 ? "Nomor dokumen diperlukan" : null,
employee: (val) =>
(val.fullName.trim().length === 0
? { fullName: "Nama lengkap diperlukan" }
: null) as any,
incomeDetails: (val) => {
if (val.netIncome.value <= 0)
return {
netIncome: { value: "Net income harus lebih dari 0" },
} as any;
return null;
},
},
});
// helper to compute net income automatically when basic/allowances/deductions change
function recalcNetIncome() {
const basic = form.values.incomeDetails.basicSalary.value || 0;
const allowances = form.values.incomeDetails.allowances.value || 0;
const deductions = form.values.incomeDetails.deductions.value || 0;
const net = basic + allowances - deductions;
form.setFieldValue(
"incomeDetails.netIncome.value",
Math.max(0, Math.round(net)),
);
}
// Submit handler: production apps would call API here
function handleSubmit(values: CertificateFormSchema) {
// simulate transformation: format currency, dates, etc.
const payload = {
...values,
issueDate: values.issueDate
? values.issueDate.toISOString().slice(0, 10)
: null,
employee: {
...values.employee,
dateOfBirth: values.employee.dateOfBirth
? values.employee.dateOfBirth.toISOString().slice(0, 10)
: null,
},
validity: {
startDate: values.validity.startDate
? values.validity.startDate.toISOString().slice(0, 10)
: null,
endDate: values.validity.endDate
? values.validity.endDate.toISOString().slice(0, 10)
: null,
description: values.validity.description,
},
};
// For demo: log and show a subtle success
// Replace with real API call (fetch/axios) in production.
console.log("Submitting Surat Keterangan Penghasilan:", payload);
alert(
"Form submitted — cek console untuk payload (demo).\nUntuk produksi, hubungkan endpoint API.",
);
}
return (
<Container size="md" w={"100%"}>
<form onSubmit={form.onSubmit((v) => handleSubmit(v))}>
<Card shadow="sm" radius="md" padding="lg" withBorder>
<SectionTitle
title="Surat Keterangan Penghasilan"
icon={IconFileText}
subtitle="Isi data sesuai blangko resmi. Gunakan tab untuk berpindah antar field."
/>
<Grid gutter="md">
<Grid.Col span={6}>
<FormField
label="Tipe Dokumen"
leftIcon={<IconFileText size={18} />}
>
<TextInput
{...form.getInputProps("documentType.value" as any)}
value={form.values.documentType}
onChange={(e) =>
form.setFieldValue("documentType", e.target.value as any)
}
placeholder="Surat Keterangan Penghasilan"
aria-label="Tipe dokumen"
/>
<Text size="xs" color="dimmed">
Jenis dokumen (tidak wajib diubah).
</Text>
</FormField>
</Grid.Col>
<Grid.Col span={6}>
<FormField
label="Nomor Dokumen"
leftIcon={<IconInfoCircle size={18} />}
description={form.values.documentNumber.description}
>
<TextInput
placeholder="e.g. SKP-2025-0001"
{...form.getInputProps("documentNumber.value" as any)}
aria-label="Nomor dokumen"
/>
</FormField>
</Grid.Col>
<Grid.Col span={12}>
<Accordion variant="separated">
<Accordion.Item value="issuer">
<Accordion.Control>
{" "}
<Group>
{" "}
<IconBuildingStore size={16} />{" "}
<Text fw={700}>Pihak Penerbit</Text>{" "}
</Group>{" "}
</Accordion.Control>
<Accordion.Panel>
<Grid>
<Grid.Col span={6}>
<TextInput
label="Nama Penerbit"
placeholder="Contoh: PT. Contoh Perkasa"
{...form.getInputProps("issuer.name" as any)}
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label="Jabatan"
placeholder="HRD / Manager"
{...form.getInputProps("issuer.position" as any)}
/>
</Grid.Col>
<Grid.Col span={12}>
<Textarea
label="Alamat Perusahaan"
placeholder="Alamat lengkap penerbit"
{...form.getInputProps("issuer.address" as any)}
minRows={2}
/>
</Grid.Col>
</Grid>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="employee">
<Accordion.Control>
{" "}
<Group>
{" "}
<IconUser size={16} />{" "}
<Text fw={700}>Data Karyawan</Text>{" "}
</Group>{" "}
</Accordion.Control>
<Accordion.Panel>
<Grid>
<Grid.Col span={6}>
<TextInput
label="Nama Lengkap"
placeholder="Nama sesuai KTP"
{...form.getInputProps("employee.fullName" as any)}
required
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label="NIK"
placeholder="Nomor Induk KTP"
{...form.getInputProps("employee.nik" as any)}
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label="Tempat Lahir"
placeholder="Kota kelahiran"
{...form.getInputProps(
"employee.placeOfBirth" as any,
)}
/>
</Grid.Col>
<Grid.Col span={6}>
<DatePicker
{...form.getInputProps("employee.dateOfBirth" as any)}
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label="Jabatan"
placeholder="Posisi di perusahaan"
{...form.getInputProps("employee.position" as any)}
/>
</Grid.Col>
<Grid.Col span={6}>
<Select
label="Status Kerja"
data={[
"Karyawan Tetap",
"Karyawan Kontrak",
"Magang",
"Konsultan",
]}
{...form.getInputProps(
"employee.employmentStatus" as any,
)}
/>
</Grid.Col>
<Grid.Col span={12}>
<Textarea
label="Catatan / Deskripsi"
placeholder="Opsional"
{...form.getInputProps("employee.description" as any)}
minRows={2}
/>
</Grid.Col>
</Grid>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="income">
<Accordion.Control>
{" "}
<Group>
{" "}
<IconCurrencyDollar size={16} />{" "}
<Text fw={700}>Rincian Penghasilan</Text>{" "}
</Group>{" "}
</Accordion.Control>
<Accordion.Panel>
<Grid>
<Grid.Col span={6}>
<NumberInput
label="Gaji Pokok (per bulan)"
pattern="^[0-9]*$"
min={0}
placeholder="0"
{...form.getInputProps(
"incomeDetails.basicSalary.value" as any,
)}
onBlur={recalcNetIncome}
/>
</Grid.Col>
<Grid.Col span={6}>
<NumberInput
label="Tunjangan (per bulan)"
pattern="^[0-9]*$"
min={0}
placeholder="0"
{...form.getInputProps(
"incomeDetails.allowances.value" as any,
)}
onBlur={recalcNetIncome}
/>
</Grid.Col>
<Grid.Col span={6}>
<NumberInput
label="Potongan (per bulan)"
pattern="^[0-9]*$"
min={0}
placeholder="0"
{...form.getInputProps(
"incomeDetails.deductions.value" as any,
)}
onBlur={recalcNetIncome}
/>
</Grid.Col>
<Grid.Col span={6}>
<NumberInput
label="Penghasilan Bersih (per bulan)"
readOnly
value={form.values.incomeDetails.netIncome.value}
pattern="^[0-9]*$"
min={0}
placeholder="0"
/>
</Grid.Col>
<Grid.Col span={12}>
<Text size="xs" color="dimmed">
{form.values.incomeDetails.basicSalary.description}
</Text>
</Grid.Col>
</Grid>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="validity">
<Accordion.Control>
{" "}
<Group>
{" "}
<IconCalendarEvent size={16} />{" "}
<Text fw={700}>Masa Berlaku</Text>{" "}
</Group>{" "}
</Accordion.Control>
<Accordion.Panel>
<Grid>
<Grid.Col span={6}>
<DatePicker
{...form.getInputProps("validity.startDate" as any)}
/>
</Grid.Col>
<Grid.Col span={6}>
<DatePicker
{...form.getInputProps("validity.endDate" as any)}
/>
</Grid.Col>
<Grid.Col span={12}>
<Textarea
label="Keterangan Masa Berlaku"
placeholder="Contoh: Berlaku 1 tahun sejak diterbitkan"
{...form.getInputProps("validity.description" as any)}
minRows={2}
/>
</Grid.Col>
</Grid>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value="signatory">
<Accordion.Control>
{" "}
<Group>
{" "}
<IconCheck size={16} />{" "}
<Text fw={700}>Penandatangan</Text>{" "}
</Group>{" "}
</Accordion.Control>
<Accordion.Panel>
<Grid>
<Grid.Col span={6}>
<TextInput
label="Nama Penandatangan"
placeholder="Nama pejabat"
{...form.getInputProps("signatory.name" as any)}
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label="Jabatan Penandatangan"
placeholder="Direktur / Manager"
{...form.getInputProps("signatory.position" as any)}
/>
</Grid.Col>
<Grid.Col span={12}>
<TextInput
label="Tanda Tangan (nama atau link)"
placeholder="bila tersedia"
{...form.getInputProps("signatory.signature" as any)}
/>
</Grid.Col>
</Grid>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Grid.Col>
<Grid.Col span={12}>
<Divider my="sm" />
<Grid>
<Grid.Col span={6}>
<DatePicker {...form.getInputProps("issueDate" as any)} />
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label="Tujuan Penggunaan"
placeholder="Contoh: Pengajuan KPR"
{...form.getInputProps("purpose.value" as any)}
/>
</Grid.Col>
</Grid>
</Grid.Col>
<Grid.Col span={12}>
<Group justify="right">
<Button
variant="default"
leftSection={<IconRefresh size={16} />}
onClick={() => form.reset()}
>
Reset
</Button>
<Button type="submit">Simpan & Cetak</Button>
</Group>
</Grid.Col>
</Grid>
</Card>
</form>
</Container>
);
}

View File

@@ -0,0 +1,502 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
// pages/sktu/page.tsx
import React, { useState } from "react";
import {
Container,
Card,
Title,
Text,
Divider,
Stack,
Group,
SimpleGrid,
TextInput,
NumberInput,
Textarea,
Select,
FileInput,
Button,
Notification,
Tooltip,
Loader,
} from "@mantine/core";
import { useForm } from "@mantine/form";
import { DatePicker } from "@mantine/dates";
import {
IconCheck,
IconX,
IconInfoCircle,
IconUser,
IconId,
IconMapPin,
IconPhone,
IconBuildingStore,
IconCategory,
IconSquarePlus,
IconRuler,
IconUsers,
IconFileText,
IconSignature,
} from "@tabler/icons-react";
// types/form-types.ts
export type StatusTempat =
| "Milik Sendiri"
| "Kontrak/Sewa"
| "Pinjam Pakai"
| "Lainnya";
export interface SKTUFormValues {
// Data Pemohon
namaLengkap: string;
nik: string;
tempatTanggalLahir: string;
alamatPemohon: string;
telepon: string;
// Data Usaha
namaUsaha: string;
jenisUsaha: string;
bidangUsaha: string;
alamatUsaha: string;
statusTempat: StatusTempat;
luasTempat: string; // kept as string to allow free-text like "36" or "36 (sebagian)"
jumlahKaryawan: number;
npwp?: string;
// Keterangan Tambahan
keteranganTambahan?: string;
// Tanggal pengajuan
tanggalPengajuan: Date | null;
// Pemohon (penandatangan)
pemohon_nama: string;
pemohon_tandaTangan: File | null;
// Pengesahan
kepalaDesaLurah: string;
camat: string;
petugasRegistrasi: string;
}
export default function FormSuratKeteranganTempatUsaha() {
const [submitting, setSubmitting] = useState(false);
const [success, setSuccess] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const form = useForm<SKTUFormValues>({
initialValues: {
// dataPemohon
namaLengkap: "",
nik: "",
tempatTanggalLahir: "",
alamatPemohon: "",
telepon: "",
// dataUsaha
namaUsaha: "",
jenisUsaha: "",
bidangUsaha: "",
alamatUsaha: "",
statusTempat: "Milik Sendiri",
luasTempat: "",
jumlahKaryawan: 0,
npwp: "",
// keteranganTambahan
keteranganTambahan: "",
// tanggalPengajuan
tanggalPengajuan: null,
// pemohon
pemohon_nama: "",
pemohon_tandaTangan: null,
// pengesahan
kepalaDesaLurah: "",
camat: "",
petugasRegistrasi: "",
},
validate: {
namaLengkap: (v: any) =>
v.trim().length > 0 ? null : "Nama lengkap pemohon diperlukan.",
nik: (v: any) => {
const digits = v.replace(/\D/g, "");
if (!digits) return "NIK diperlukan.";
if (digits.length !== 16) return "NIK harus 16 digit angka.";
return null;
},
tempatTanggalLahir: (v: any) =>
v.trim().length > 0 ? null : "Tempat dan tanggal lahir diperlukan.",
alamatPemohon: (v: any) =>
v.trim().length > 0 ? null : "Alamat sesuai KTP diperlukan.",
telepon: (v: any) => {
const digits = v.replace(/\D/g, "");
if (!digits) return "Nomor telepon diperlukan.";
if (digits.length < 8) return "Nomor telepon tampak terlalu pendek.";
return null;
},
namaUsaha: (v: any) => (v.trim() ? null : "Nama usaha diperlukan."),
jenisUsaha: (v: any) => (v.trim() ? null : "Jenis usaha diperlukan."),
alamatUsaha: (v: any) => (v.trim() ? null : "Alamat usaha diperlukan."),
luasTempat: (v: any) => {
if (!v.trim()) return "Luas tempat diperlukan.";
const n = Number(v);
if (Number.isNaN(n) || n <= 0)
return "Masukkan luas valid (nomor > 0).";
return null;
},
jumlahKaryawan: (v: any) =>
typeof v === "number" && v >= 0 ? null : "Jumlah karyawan harus >= 0.",
tanggalPengajuan: (v: any) =>
v ? null : "Tanggal pengajuan harus diisi.",
pemohon_nama: (v: any) =>
v.trim() ? null : "Nama pemohon (penandatangan) diperlukan.",
kepalaDesaLurah: (v: any) =>
v.trim() ? null : "Nama kepala desa/lurah diperlukan.",
camat: (v: any) => (v.trim() ? null : "Nama camat diperlukan."),
petugasRegistrasi: (v: any) =>
v.trim() ? null : "Nama petugas registrasi diperlukan.",
},
});
async function handleSubmit(values: SKTUFormValues) {
setError(null);
setSuccess(null);
setSubmitting(true);
try {
// Simulasi panggilan API
await new Promise((res) => setTimeout(res, 1100));
// Example: convert file to metadata, transform date to ISO
const payload = {
...values,
tanggalPengajuan: values.tanggalPengajuan
? values.tanggalPengajuan.toISOString()
: null,
pemohon_tandaTangan: values.pemohon_tandaTangan
? values.pemohon_tandaTangan.name
: null,
};
// TODO: ganti dengan fetch() ke API nyata
console.log("SKTU payload", payload);
setSuccess("Permohonan SKTU berhasil dikirim.");
form.reset();
} catch (err) {
console.error(err);
setError("Gagal mengirim permohonan. Coba lagi.");
} finally {
setSubmitting(false);
}
}
return (
<Container size="sm" py="xl">
<Stack justify="xl">
<Card shadow="sm" radius="md" withBorder>
<Group justify="apart" align="flex-start" wrap="nowrap">
<div>
<Title order={2} aria-live="polite">
Formulir Surat Keterangan Tempat Usaha (SKTU)
</Title>
<Text size="sm" color="dimmed" mt={6}>
Blangko resmi untuk pengajuan SKTU digunakan sebagai bukti
legalitas usaha.
</Text>
</div>
<Tooltip
label="Form ini membantu mengumpulkan data pemohon dan usaha"
withArrow
>
<IconInfoCircle size={20} aria-hidden />
</Tooltip>
</Group>
<Divider my="md" />
<form onSubmit={form.onSubmit(handleSubmit)} aria-label="Form SKTU">
<Stack justify="lg">
{/* Data Pemohon */}
<Card
withBorder
radius="md"
p="md"
aria-labelledby="pemohon-heading"
>
<Group justify="apart" align="flex-start" wrap="nowrap">
<Title order={4} id="pemohon-heading">
Data Pemohon
</Title>
</Group>
<SimpleGrid cols={1} mt="md">
<TextInput
required
label="Nama Lengkap"
placeholder="Nama sesuai KTP"
leftSection={<IconUser size={18} />}
{...form.getInputProps("namaLengkap")}
aria-label="Nama Lengkap"
description="Nama pemilik usaha sesuai KTP."
/>
<TextInput
required
label="NIK"
placeholder="16 digit NIK"
leftSection={<IconId size={18} />}
{...form.getInputProps("nik")}
aria-label="NIK"
inputMode="numeric"
description="Masukkan 16 digit NIK (hanya angka)."
/>
</SimpleGrid>
<SimpleGrid cols={1} mt="sm">
<TextInput
required
label="Tempat & Tanggal Lahir"
placeholder="Contoh: Denpasar, 01 Januari 1990"
leftSection={<IconMapPin size={18} />}
{...form.getInputProps("tempatTanggalLahir")}
aria-label="Tempat dan tanggal lahir"
description="Cantumkan tempat dan tanggal lahir (format bebas)."
/>
<TextInput
required
label="Telepon"
placeholder="08xxxxxxxxxx"
leftSection={<IconPhone size={18} />}
{...form.getInputProps("telepon")}
aria-label="Telepon"
description="Nomor yang dapat dihubungi untuk verifikasi."
/>
</SimpleGrid>
<Textarea
mt="sm"
required
label="Alamat Pemohon"
placeholder="Alamat lengkap sesuai KTP"
minRows={2}
{...form.getInputProps("alamatPemohon")}
aria-label="Alamat Pemohon"
/>
</Card>
{/* Data Usaha */}
<Card
withBorder
radius="md"
p="md"
aria-labelledby="usaha-heading"
>
<Title order={4} id="usaha-heading">
Data Usaha
</Title>
<SimpleGrid cols={1} mt="md">
<TextInput
required
label="Nama Usaha"
placeholder="Nama usaha / toko"
leftSection={<IconBuildingStore size={18} />}
{...form.getInputProps("namaUsaha")}
aria-label="Nama Usaha"
description="Nama dagang yang digunakan di lapangan."
/>
<TextInput
required
label="Jenis Usaha"
placeholder="Contoh: Dagang, Jasa, Produksi"
leftSection={<IconCategory size={18} />}
{...form.getInputProps("jenisUsaha")}
aria-label="Jenis Usaha"
description="Pilih/isi jenis usaha secara singkat."
/>
</SimpleGrid>
<TextInput
mt="sm"
label="Bidang Usaha"
placeholder="Contoh: Warung makan, bengkel motor"
{...form.getInputProps("bidangUsaha")}
description="Spesifikkan bidang usaha Anda (opsional tapi direkomendasikan)."
leftSection={<IconSquarePlus size={18} />}
/>
<Textarea
mt="sm"
required
label="Alamat Usaha"
placeholder="Alamat lengkap tempat usaha"
minRows={2}
{...form.getInputProps("alamatUsaha")}
leftSection={<IconMapPin size={18} />}
/>
<SimpleGrid cols={1} mt="sm">
<Select
label="Status Tempat"
data={[
"Milik Sendiri",
"Kontrak/Sewa",
"Pinjam Pakai",
"Lainnya",
]}
{...form.getInputProps("statusTempat")}
description="Status kepemilikan atau penggunaan tempat usaha."
aria-label="Status Tempat"
/>
<TextInput
label="Luas Tempat (m²)"
placeholder="Contoh: 36"
leftSection={<IconRuler size={18} />}
{...form.getInputProps("luasTempat")}
aria-label="Luas Tempat"
description="Isi angka luas bangunan / ruangan (meter persegi)."
/>
<NumberInput
label="Jumlah Karyawan"
min={0}
step={1}
{...form.getInputProps("jumlahKaryawan")}
leftSection={<IconUsers size={18} />}
aria-label="Jumlah Karyawan"
description="Jumlah pekerja tetap/harian di usaha ini."
/>
</SimpleGrid>
<TextInput
mt="sm"
label="NPWP (jika ada)"
placeholder="Nomor NPWP"
{...form.getInputProps("npwp")}
leftSection={<IconFileText size={18} />}
/>
</Card>
{/* Keterangan Tambahan & Tanggal */}
<Card withBorder radius="md" p="md">
<Title order={5}>Keterangan Tambahan & Tanggal</Title>
<Textarea
mt="sm"
label="Keterangan Tambahan (opsional)"
placeholder="Informasi tambahan mengenai usaha"
minRows={3}
{...form.getInputProps("keteranganTambahan")}
/>
<DatePicker
mt="sm"
{...form.getInputProps("tanggalPengajuan")}
aria-label="Tanggal Pengajuan"
/>
</Card>
{/* Pemohon (penandatangan) */}
<Card withBorder radius="md" p="md">
<Title order={5}>Pemohon (Penandatangan)</Title>
<SimpleGrid cols={1} mt="md">
<TextInput
required
label="Nama Pemohon (penandatangan)"
placeholder="Nama yang menandatangani surat"
{...form.getInputProps("pemohon_nama")}
leftSection={<IconUser size={18} />}
aria-label="Nama Penandatangan"
/>
<FileInput
label="Tanda Tangan (scan / file)"
placeholder="Upload file tanda tangan (jpg, png, pdf)"
accept="image/png, image/jpeg, application/pdf"
{...form.getInputProps("pemohon_tandaTangan")}
leftSection={<IconSignature size={18} />}
aria-label="Tanda Tangan"
description="Scan tanda tangan yang digunakan untuk verifikasi."
/>
</SimpleGrid>
</Card>
{/* Pengesahan */}
<Card withBorder radius="md" p="md">
<Title order={5}>Pengesahan</Title>
<SimpleGrid cols={1} mt="md">
<TextInput
required
label="Kepala Desa / Lurah"
placeholder="Nama Kepala Desa atau Lurah"
{...form.getInputProps("kepalaDesaLurah")}
aria-label="Kepala Desa Lurah"
/>
<TextInput
required
label="Camat"
placeholder="Nama Camat"
{...form.getInputProps("camat")}
aria-label="Camat"
/>
<TextInput
required
label="Petugas Registrasi"
placeholder="Nama petugas registrasi"
{...form.getInputProps("petugasRegistrasi")}
aria-label="Petugas Registrasi"
/>
</SimpleGrid>
</Card>
{/* Submission + Feedback */}
<Group justify="right" mt="md">
<Button
type="submit"
leftSection={
submitting ? <Loader size={16} /> : <IconCheck size={16} />
}
disabled={submitting}
>
{submitting ? "Mengirim..." : "Kirim Permohonan"}
</Button>
</Group>
{success && (
<Notification
icon={<IconCheck size={18} />}
color="teal"
onClose={() => setSuccess(null)}
title="Berhasil"
>
{success}
</Notification>
)}
{error && (
<Notification
icon={<IconX size={18} />}
color="red"
onClose={() => setError(null)}
title="Error"
>
{error}
</Notification>
)}
</Stack>
</form>
</Card>
<Text size="xs" color="dimmed" ta="center">
Pastikan semua data telah terisi sesuai dokumen resmi. Untuk bantuan,
hubungi kantor kelurahan setempat.
</Text>
</Stack>
</Container>
);
}

View File

@@ -0,0 +1,600 @@
import {
Accordion,
ActionIcon,
Badge,
Box,
Button,
Card,
Container,
Divider,
FileInput,
Grid,
Group,
Select,
Stack,
Text,
TextInput,
Textarea,
Tooltip,
} from "@mantine/core";
import { DatePicker } from "@mantine/dates";
import { useForm } from "@mantine/form";
import {
IconBuildingCommunity,
IconCalendarEvent,
IconInfoCircle,
IconMailCheck,
IconMapPin,
IconUser,
} from "@tabler/icons-react";
import React from "react";
// =========================
// Types (strong typing for the form state)
// =========================
type Header = {
instansi: string;
kecamatan: string;
desaKelurahan: string;
nomorSurat: string;
};
type DataPemohon = {
namaLengkap: string;
nik: string;
tempatTanggalLahir: string;
jenisKelamin: "Laki-laki" | "Perempuan" | "";
agama: string;
statusPerkawinan: string;
pekerjaan: string;
alamat: string;
rt: string;
rw: string;
desaKelurahan: string;
kecamatan: string;
kabupatenKota: string;
};
type Keterangan = {
isiSurat: string;
keperluan: string;
};
type Penutup = {
tempat: string;
tanggal: Date | null;
};
type Pengesahan = {
kepalaDesaLurah: string;
jabatan: string;
tandaTangan: File | null;
stempel: File | null;
};
type SKTMFormValues = {
header: Header;
dataPemohon: DataPemohon;
keterangan: Keterangan;
penutup: Penutup;
pengesahan: Pengesahan;
};
// =========================
// Reusable UI components
// =========================
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,
}: {
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>
);
}
// =========================
// Helper validators
// =========================
const isRequired = (val: any) =>
val === undefined || val === null || String(val).trim() === ""
? "Wajib diisi"
: null;
const validateNIK = (val: string) => {
if (!val) return "Wajib diisi";
const digits = val.replace(/\D/g, "");
if (digits.length !== 16) return "NIK harus 16 digit";
return null;
};
// =========================
// Main form component
// =========================
export default function FormSuratKeteranganTidakMampu() {
// initialize form with sensible defaults
const form = useForm<SKTMFormValues>({
initialValues: {
header: {
instansi: "PEMERINTAH KABUPATEN / KOTA",
kecamatan: "",
desaKelurahan: "",
nomorSurat: "",
},
dataPemohon: {
namaLengkap: "",
nik: "",
tempatTanggalLahir: "",
jenisKelamin: "",
agama: "",
statusPerkawinan: "",
pekerjaan: "",
alamat: "",
rt: "",
rw: "",
desaKelurahan: "",
kecamatan: "",
kabupatenKota: "",
},
keterangan: {
isiSurat: "",
keperluan: "",
},
penutup: {
tempat: "",
tanggal: null,
},
pengesahan: {
kepalaDesaLurah: "",
jabatan: "Kepala Desa",
tandaTangan: null,
stempel: null,
},
},
validate: {
// header validators
header: {
instansi: (val) => isRequired(val),
kecamatan: (val) => isRequired(val),
desaKelurahan: (val) => isRequired(val),
nomorSurat: (val) => isRequired(val),
},
// data pemohon validators
dataPemohon: {
namaLengkap: (val) => isRequired(val),
nik: (val) => validateNIK(val),
tempatTanggalLahir: (val) => isRequired(val),
jenisKelamin: (val) => (val ? null : "Pilih jenis kelamin"),
agama: (val) => isRequired(val),
statusPerkawinan: (val) => isRequired(val),
pekerjaan: (val) => isRequired(val),
alamat: (val) => isRequired(val),
rt: (val) => isRequired(val),
rw: (val) => isRequired(val),
desaKelurahan: (val) => isRequired(val),
kecamatan: (val) => isRequired(val),
kabupatenKota: (val) => isRequired(val),
},
// keterangan
keterangan: {
isiSurat: (val) => isRequired(val),
keperluan: (val) => isRequired(val),
},
// penutup
penutup: {
tempat: (val) => isRequired(val),
tanggal: (val) => (val ? null : "Pilih tanggal penerbitan"),
},
// pengesahan
pengesahan: {
kepalaDesaLurah: (val) => isRequired(val),
jabatan: (val) => isRequired(val),
},
},
});
// Submit handler
const handleSubmit = (values: SKTMFormValues) => {
// Convert files to metadata or prepare multipart form if needed.
// Here we'll just console.log for demo purposes.
console.log("Form submitted:", values);
// In production: send to API endpoint (multipart/form-data) or convert File to base64.
};
return (
<Container size="md" w={"100%"}>
<Box>
<Stack gap="lg">
<Group justify="apart" align="center">
<Group align="center">
<IconBuildingCommunity size={28} />
<div>
<Text fw={800} size="xl">
Surat Keterangan Tidak Mampu (SKTM)
</Text>
<Text size="sm" c="dimmed">
Blangko resmi untuk pengajuan Surat Keterangan Tidak Mampu
digunakan untuk keperluan pendidikan, kesehatan, atau
administrasi.
</Text>
</div>
</Group>
<Group>
<Badge radius="sm">Form Length: 5 Sections</Badge>
</Group>
</Group>
<form
onSubmit={form.onSubmit((values) => {
handleSubmit(values);
})}
>
<Stack gap="lg">
{/* Header Section */}
<FormSection
title="Header Surat"
icon={<IconMailCheck size={20} />}
description="Informasi identitas surat"
>
<Grid>
<Grid.Col span={6}>
<TextInput
label={
<FieldLabel
label="Instansi Penerbit"
hint="Contoh: PEMERINTAH KABUPATEN/KOTA"
/>
}
placeholder="PEMERINTAH KABUPATEN/KOTA"
{...form.getInputProps("header.instansi")}
leftSection={<IconBuildingCommunity size={16} />}
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label={
<FieldLabel
label="Nomor Surat"
hint="Nomor resmi surat"
/>
}
placeholder="123/SKTM/2025"
{...form.getInputProps("header.nomorSurat")}
/>
</Grid.Col>
<Grid.Col span={4}>
<TextInput
label={<FieldLabel label="Kecamatan" />}
placeholder="Kecamatan"
{...form.getInputProps("header.kecamatan")}
/>
</Grid.Col>
<Grid.Col span={4}>
<TextInput
label={<FieldLabel label="Desa / Kelurahan" />}
placeholder="Desa / Kelurahan"
{...form.getInputProps("header.desaKelurahan")}
/>
</Grid.Col>
</Grid>
</FormSection>
{/* Data Pemohon Section */}
<Accordion variant="separated" radius="md" defaultValue="pemohon">
<Accordion.Item value="pemohon">
<Accordion.Control icon={<IconUser size={16} />}>
Data Pemohon
</Accordion.Control>
<Accordion.Panel>
<FormSection
title="Data Pemohon"
description="Informasi identitas pemohon"
>
<Grid>
<Grid.Col span={6}>
<TextInput
label={
<FieldLabel
label="Nama Lengkap"
hint="Sesuai KTP"
/>
}
placeholder="Nama lengkap"
{...form.getInputProps("dataPemohon.namaLengkap")}
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label={
<FieldLabel
label="NIK"
hint="16 digit, tanpa spasi"
/>
}
placeholder="3201xxxxxxxxxxxx"
{...form.getInputProps("dataPemohon.nik")}
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label={
<FieldLabel
label="Tempat, Tanggal Lahir"
hint="Contoh: Denpasar, 31-12-1990"
/>
}
placeholder="Tempat, tanggal lahir"
{...form.getInputProps(
"dataPemohon.tempatTanggalLahir",
)}
/>
</Grid.Col>
<Grid.Col span={6}>
<Select
label={<FieldLabel label="Jenis Kelamin" />}
placeholder="Pilih jenis kelamin"
data={["Laki-laki", "Perempuan"]}
{...form.getInputProps("dataPemohon.jenisKelamin")}
/>
</Grid.Col>
<Grid.Col span={6}>
<Select
label={<FieldLabel label="Agama" />}
placeholder="Pilih agama"
data={[
"Islam",
"Kristen",
"Katolik",
"Hindu",
"Buddha",
"Konghucu",
"Lainnya",
]}
{...form.getInputProps("dataPemohon.agama")}
/>
</Grid.Col>
<Grid.Col span={6}>
<Select
label={<FieldLabel label="Status Perkawinan" />}
placeholder="Pilih status"
data={[
"Belum Kawin",
"Kawin",
"Cerai Hidup",
"Cerai Mati",
]}
{...form.getInputProps(
"dataPemohon.statusPerkawinan",
)}
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label={<FieldLabel label="Pekerjaan" />}
placeholder="Pekerjaan"
{...form.getInputProps("dataPemohon.pekerjaan")}
/>
</Grid.Col>
<Grid.Col span={12}>
<Textarea
label={<FieldLabel label="Alamat Lengkap" />}
placeholder="Alamat domisili"
minRows={2}
{...form.getInputProps("dataPemohon.alamat")}
/>
</Grid.Col>
<Grid.Col span={2}>
<TextInput
label={<FieldLabel label="RT" />}
placeholder="001"
{...form.getInputProps("dataPemohon.rt")}
/>
</Grid.Col>
<Grid.Col span={2}>
<TextInput
label={<FieldLabel label="RW" />}
placeholder="002"
{...form.getInputProps("dataPemohon.rw")}
/>
</Grid.Col>
<Grid.Col span={4}>
<TextInput
label={<FieldLabel label="Desa / Kelurahan" />}
placeholder="Desa"
{...form.getInputProps("dataPemohon.desaKelurahan")}
/>
</Grid.Col>
<Grid.Col span={4}>
<TextInput
label={<FieldLabel label="Kecamatan" />}
placeholder="Kecamatan"
{...form.getInputProps("dataPemohon.kecamatan")}
/>
</Grid.Col>
<Grid.Col span={4}>
<TextInput
label={<FieldLabel label="Kabupaten / Kota" />}
placeholder="Kabupaten / Kota"
{...form.getInputProps("dataPemohon.kabupatenKota")}
/>
</Grid.Col>
</Grid>
</FormSection>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
{/* Keterangan Section */}
<FormSection
title="Keterangan"
icon={<IconMapPin size={18} />}
description="Isi pernyataan SKTM"
>
<Textarea
label={
<FieldLabel
label="Isi Surat"
hint="Jelaskan kondisi ekonomi secara singkat"
/>
}
placeholder="Pernyataan resmi bahwa yang bersangkutan benar-benar tergolong keluarga tidak mampu..."
minRows={4}
{...form.getInputProps("keterangan.isiSurat")}
/>
<TextInput
label={
<FieldLabel
label="Keperluan"
hint="Contoh: Beasiswa pendidikan / Perawatan kesehatan"
/>
}
placeholder="Keperluan surat"
{...form.getInputProps("keterangan.keperluan")}
/>
</FormSection>
{/* Penutup Section */}
<FormSection
title="Penutup"
icon={<IconCalendarEvent size={18} />}
description="Tempat dan tanggal penerbitan"
>
<Grid>
<Grid.Col span={6}>
<TextInput
label={<FieldLabel label="Tempat" />}
placeholder="Contoh: Denpasar"
{...form.getInputProps("penutup.tempat")}
/>
</Grid.Col>
<Grid.Col span={6}>
<DatePicker {...form.getInputProps("penutup.tanggal")} />
</Grid.Col>
</Grid>
</FormSection>
{/* Pengesahan Section */}
<FormSection
title="Pengesahan"
description="Tanda tangan & stempel instansi"
>
<Grid>
<Grid.Col span={6}>
<TextInput
label={<FieldLabel label="Kepala Desa / Lurah" />}
placeholder="Nama pejabat"
{...form.getInputProps("pengesahan.kepalaDesaLurah")}
/>
</Grid.Col>
<Grid.Col span={6}>
<TextInput
label={<FieldLabel label="Jabatan" />}
placeholder="Contoh: Kepala Desa"
{...form.getInputProps("pengesahan.jabatan")}
/>
</Grid.Col>
<Grid.Col span={6}>
<FileInput
label={
<FieldLabel
label="Scan Tanda Tangan"
hint="Upload file scan tanda tangan (PNG/JPG/PDF)"
/>
}
placeholder="Pilih file..."
accept="image/png, image/jpeg, .pdf"
{...form.getInputProps("pengesahan.tandaTangan")}
/>
</Grid.Col>
<Grid.Col span={6}>
<FileInput
label={
<FieldLabel
label="Stempel / Cap"
hint="Upload file stempel (PNG/JPG/PDF)"
/>
}
placeholder="Pilih file..."
accept="image/png, image/jpeg, .pdf"
{...form.getInputProps("pengesahan.stempel")}
/>
</Grid.Col>
</Grid>
</FormSection>
{/* Actions */}
<Group justify="right" mt="md">
<Button variant="default" onClick={() => form.reset()}>
Reset
</Button>
<Button type="submit">Kirim / Simpan</Button>
</Group>
</Stack>
</form>
<Text size="xs" c="dimmed">
Tip: Form ini otomatis menerjemahkan skema JSON ke komponen Mantine.
Anda dapat memperluas validasi (contoh: cek format NIK, unggah file
maksimal 2MB, dsb) sesuai kebutuhan produksi.
</Text>
</Stack>
</Box>
</Container>
);
}

View File

@@ -0,0 +1,641 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import {
Accordion,
Button,
Card,
Container,
Divider,
FileInput,
Grid,
Group,
Notification,
Select,
Stack,
Text,
TextInput,
Textarea,
Title,
Tooltip,
} from "@mantine/core";
import { DatePicker } from "@mantine/dates";
import { useForm } from "@mantine/form";
import {
IconBuildingStore,
IconCheck,
IconClipboardText,
IconFileText,
IconId,
IconInfoCircle,
IconUser,
IconX,
} from "@tabler/icons-react";
import React from "react";
/* -------------------------
Types derived from schema
------------------------- */
type PemohonData = {
namaLengkap: string;
nik: string;
tempatTanggalLahir: string;
jenisKelamin: "Laki-laki" | "Perempuan" | "";
pekerjaan: string;
alamat: string;
desaKelurahan: string;
kecamatan: string;
kabupatenKota: string;
};
type UsahaData = {
namaUsaha: string;
jenisUsaha: string;
alamatUsaha: string;
lamaUsaha: string;
statusTempat: "Milik Sendiri" | "Sewa/Kontrak" | "Pinjam" | "";
};
type PemohonSignature = {
nama: string;
tandaTangan: File | null;
};
type Pengesahan = {
kepalaDesaLurah: string;
camat?: string;
stempel?: File | null;
};
export type SkuFormValues = {
dataPemohon: PemohonData;
dataUsaha: UsahaData;
tujuanPembuatan: string;
tanggalPengajuan: Date | null;
pemohon: PemohonSignature;
pengesahan: Pengesahan;
};
/* -------------------------
Reusable small components
------------------------- */
/**
* FormField:
* Maps a tiny field descriptor to a Mantine input with label, description and error handling.
* For brevity each field mapping is explicit — easy to extend to a dynamic mapping.
*/
function FormField(props: {
id: string;
label: string;
description?: string;
children: React.ReactNode;
}) {
const { id, label, description, children } = props;
return (
<Stack style={{ width: "100%" }}>
<Group justify="apart" style={{ alignItems: "flex-start" }}>
<Text fw={600} c="dark" id={`${id}-label`}>
{label}
</Text>
{description ? (
<Tooltip label={description} withArrow>
<IconInfoCircle size={18} aria-hidden />
</Tooltip>
) : null}
</Group>
<div aria-labelledby={`${id}-label`}>{children}</div>
{description ? (
<Text size="xs" c="dimmed">
{description}
</Text>
) : null}
</Stack>
);
}
/**
* FormSection:
* Collapsible card/accordion for grouping nested objects (dataPemohon, dataUsaha, pengesahan).
*/
function FormSection(props: {
title: string;
icon?: React.ReactNode;
children: React.ReactNode;
defaultOpened?: boolean;
}) {
const { title, icon, children, defaultOpened = true } = props;
return (
<Accordion multiple defaultValue={defaultOpened ? ["section"] : []}>
<Accordion.Item value="section">
<Accordion.Control icon={icon ?? <IconClipboardText size={18} />}>
<Text fw={700}>{title}</Text>
</Accordion.Control>
<Accordion.Panel>{children}</Accordion.Panel>
</Accordion.Item>
</Accordion>
);
}
/* -------------------------
Main Dynamic Form Component
------------------------- */
export default function FormSuratKeteranganUsaha() {
// initial values follow the schema and provide sensible defaults.
const form = useForm<SkuFormValues>({
initialValues: {
dataPemohon: {
namaLengkap: "",
nik: "",
tempatTanggalLahir: "",
jenisKelamin: "",
pekerjaan: "",
alamat: "",
desaKelurahan: "",
kecamatan: "",
kabupatenKota: "",
},
dataUsaha: {
namaUsaha: "",
jenisUsaha: "",
alamatUsaha: "",
lamaUsaha: "",
statusTempat: "",
},
tujuanPembuatan: "",
tanggalPengajuan: null,
pemohon: {
nama: "",
tandaTangan: null,
},
pengesahan: {
kepalaDesaLurah: "",
camat: "",
stempel: null,
},
},
// Validation rules inspired by schema descriptions
validate: {
dataPemohon: {
namaLengkap: (v) =>
v.trim().length === 0 ? "Nama lengkap harus diisi" : null,
nik: (v) =>
v.trim().length === 0
? "NIK harus diisi"
: !/^\d{16}$/.test(v.trim())
? "NIK harus berupa 16 digit angka"
: null,
tempatTanggalLahir: (v) =>
v.trim().length === 0 ? "Tempat/tanggal lahir harus diisi" : null,
jenisKelamin: (v) => (v === "" ? "Pilih jenis kelamin" : null),
pekerjaan: (v) =>
v.trim().length === 0 ? "Pekerjaan harus diisi" : null,
alamat: (v) => (v.trim().length === 0 ? "Alamat harus diisi" : null),
desaKelurahan: (v) =>
v.trim().length === 0 ? "Nama desa/kelurahan harus diisi" : null,
kecamatan: (v) =>
v.trim().length === 0 ? "Kecamatan harus diisi" : null,
kabupatenKota: (v) =>
v.trim().length === 0 ? "Kabupaten/Kota harus diisi" : null,
},
dataUsaha: {
namaUsaha: (v) =>
v.trim().length === 0 ? "Nama usaha harus diisi" : null,
jenisUsaha: (v) =>
v.trim().length === 0 ? "Jenis usaha harus diisi" : null,
alamatUsaha: (v) =>
v.trim().length === 0 ? "Alamat usaha harus diisi" : null,
lamaUsaha: (v) =>
v.trim().length === 0 ? "Lama usaha harus diisi" : null,
statusTempat: (v) =>
v === "" ? "Pilih status kepemilikan tempat usaha" : null,
},
tujuanPembuatan: (v) =>
v.trim().length === 0 ? "Tujuan pembuatan harus diisi" : null,
tanggalPengajuan: (v) => (v === null ? "Pilih tanggal pengajuan" : null),
pemohon: {
nama: (v) =>
v.trim().length === 0 ? "Nama pemohon harus diisi" : null,
// tandaTangan optional but we can require at least a file for UX if desired:
tandaTangan: (_) => null,
},
pengesahan: {
kepalaDesaLurah: (v) =>
v.trim().length === 0 ? "Nama kepala desa/lurah harus diisi" : null,
camat: () => null,
stempel: () => null,
},
},
});
// Simulated submit handler — replace with real API call.
const [submitStatus, setSubmitStatus] = React.useState<{
success: boolean;
message: string;
} | null>(null);
const handleSubmit = (values: SkuFormValues) => {
// We intentionally don't send anything asynchronously here in this demo.
// Convert File objects to metadata strings for preview if present.
const payload = {
...values,
pemohon: {
...values.pemohon,
tandaTangan: values.pemohon.tandaTangan
? values.pemohon.tandaTangan.name
: null,
},
pengesahan: {
...values.pengesahan,
stempel: values.pengesahan.stempel
? values.pengesahan.stempel.name
: null,
},
tanggalPengajuan: values.tanggalPengajuan
? values.tanggalPengajuan.toISOString().slice(0, 10)
: null,
};
// For now: show success and JSON preview
setSubmitStatus({
success: true,
message: "Form berhasil divalidasi. Lihat payload.",
});
console.log("SKU payload:", payload);
};
const handleReset = () => {
form.reset();
setSubmitStatus(null);
};
return (
<Container size={"md"} w={"100%"}>
<Card shadow="sm" radius="md" p="lg">
<Stack gap="md">
<Group justify="apart" gap="sm">
<Title order={3}>
<Group>
<IconClipboardText size={22} />
<span>Surat Keterangan Usaha (SKU)</span>
</Group>
</Title>
<Text size="sm" c="dimmed">
Blangko resmi dari desa/kelurahan
</Text>
</Group>
<Text size="sm" c="dimmed">
Blangko resmi untuk keterangan usaha dari pemerintah desa/kelurahan
sebagai syarat administrasi.
</Text>
{/* Data Pemohon Section */}
<FormSection title="Data Pemohon" icon={<IconUser size={18} />}>
<Grid>
<Grid.Col span={12}>
<FormField
id="namaLengkap"
label="Nama Lengkap"
description="Nama lengkap pemohon sesuai KTP."
>
<TextInput
placeholder="Contoh: Budi Santoso"
{...form.getInputProps("dataPemohon.namaLengkap")}
aria-describedby="namaLengkap-desc"
/>
</FormField>
</Grid.Col>
<Grid.Col span={6}>
<FormField
id="nik"
label="NIK"
description="Nomor Induk Kependudukan (16 digit)."
>
<TextInput
placeholder="16 digit NIK"
maxLength={16}
inputMode="numeric"
{...form.getInputProps("dataPemohon.nik")}
aria-describedby="nik-desc"
leftSection={<IconId size={16} />}
/>
</FormField>
</Grid.Col>
<Grid.Col span={6}>
<FormField
id="ttl"
label="Tempat, Tanggal Lahir"
description="Contoh: Denpasar, 1 Januari 1990"
>
<TextInput
placeholder="Tempat, Tanggal Lahir"
{...form.getInputProps("dataPemohon.tempatTanggalLahir")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={6}>
<FormField
id="jenisKelamin"
label="Jenis Kelamin"
description="Pilih jenis kelamin pemohon."
>
<Select
placeholder="Pilih"
data={["Laki-laki", "Perempuan"]}
{...form.getInputProps("dataPemohon.jenisKelamin")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={6}>
<FormField
id="pekerjaan"
label="Pekerjaan"
description="Pekerjaan utama pemohon."
>
<TextInput
placeholder="Pekerjaan"
{...form.getInputProps("dataPemohon.pekerjaan")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={12}>
<FormField
id="alamat"
label="Alamat"
description="Alamat lengkap sesuai domisili."
>
<Textarea
placeholder="Alamat lengkap"
minRows={2}
{...form.getInputProps("dataPemohon.alamat")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={4}>
<FormField
id="desaKelurahan"
label="Desa / Kelurahan"
description="Nama desa/kelurahan."
>
<TextInput
{...form.getInputProps("dataPemohon.desaKelurahan")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={4}>
<FormField
id="kecamatan"
label="Kecamatan"
description="Nama kecamatan."
>
<TextInput {...form.getInputProps("dataPemohon.kecamatan")} />
</FormField>
</Grid.Col>
<Grid.Col span={4}>
<FormField
id="kabupatenKota"
label="Kabupaten / Kota"
description="Nama kabupaten/kota."
>
<TextInput
{...form.getInputProps("dataPemohon.kabupatenKota")}
/>
</FormField>
</Grid.Col>
</Grid>
</FormSection>
<Divider />
{/* Data Usaha Section */}
<FormSection
title="Data Usaha"
icon={<IconBuildingStore size={18} />}
>
<Grid>
<Grid.Col span={6}>
<FormField id="namaUsaha" label="Nama Usaha">
<TextInput
placeholder="Nama usaha"
{...form.getInputProps("dataUsaha.namaUsaha")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={6}>
<FormField
id="jenisUsaha"
label="Jenis Usaha"
description="Contoh: warung makan, bengkel, toko kelontong"
>
<TextInput
placeholder="Jenis usaha"
{...form.getInputProps("dataUsaha.jenisUsaha")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={12}>
<FormField id="alamatUsaha" label="Alamat Usaha">
<Textarea
placeholder="Alamat lengkap lokasi usaha"
{...form.getInputProps("dataUsaha.alamatUsaha")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={6}>
<FormField
id="lamaUsaha"
label="Lama Usaha"
description="Contoh: 3 tahun"
>
<TextInput
placeholder="Lama usaha (mis. 3 tahun)"
{...form.getInputProps("dataUsaha.lamaUsaha")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={6}>
<FormField
id="statusTempat"
label="Status Tempat"
description="Pilih status kepemilikan tempat usaha"
>
<Select
placeholder="Pilih..."
data={["Milik Sendiri", "Sewa/Kontrak", "Pinjam"]}
{...form.getInputProps("dataUsaha.statusTempat")}
/>
</FormField>
</Grid.Col>
</Grid>
</FormSection>
<Divider />
{/* Tujuan & Tanggal */}
<Grid>
<Grid.Col span={8}>
<FormField
id="tujuanPembuatan"
label="Tujuan Pembuatan"
description="Contoh: pengajuan kredit bank"
>
<Textarea
placeholder="Tujuan permohonan SKU"
minRows={2}
{...form.getInputProps("tujuanPembuatan")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={4}>
<FormField
id="tanggalPengajuan"
label="Tanggal Pengajuan"
description="Tanggal pemohon mengajukan permohonan."
>
<DatePicker
value={form.values.tanggalPengajuan}
onChange={(d) =>
form.setFieldValue("tanggalPengajuan", d as any)
}
aria-label="Tanggal Pengajuan"
/>
</FormField>
</Grid.Col>
</Grid>
<Divider />
{/* Pemohon Signature */}
<FormSection
title="Pemohon (Tanda Tangan)"
icon={<IconFileText size={18} />}
>
<Grid>
<Grid.Col span={6}>
<FormField
id="pemohonNama"
label="Nama Pemohon"
description="Ditulis ulang sebagai tanda tangan."
>
<TextInput
{...form.getInputProps("pemohon.nama")}
placeholder="Nama pemohon"
/>
</FormField>
</Grid.Col>
<Grid.Col span={6}>
<FormField
id="tandaTangan"
label="Tanda Tangan (scan/file)"
description="Unggah file tanda tangan (scan)."
>
<FileInput
placeholder="Pilih file"
accept="image/*, .pdf"
value={form.values.pemohon.tandaTangan}
onChange={(f) =>
form.setFieldValue("pemohon.tandaTangan", f)
}
/>
</FormField>
</Grid.Col>
</Grid>
</FormSection>
<Divider />
{/* Pengesahan */}
<FormSection title="Pengesahan" icon={<IconCheck size={18} />}>
<Grid>
<Grid.Col span={6}>
<FormField id="kepalaDesa" label="Kepala Desa / Lurah">
<TextInput
{...form.getInputProps("pengesahan.kepalaDesaLurah")}
/>
</FormField>
</Grid.Col>
<Grid.Col span={6}>
<FormField id="camat" label="Camat (opsional)">
<TextInput {...form.getInputProps("pengesahan.camat")} />
</FormField>
</Grid.Col>
<Grid.Col span={12}>
<FormField
id="stempel"
label="Stempel (opsional)"
description="Unggah gambar stempel resmi jika tersedia."
>
<FileInput
placeholder="Pilih file stempel"
accept="image/*, .pdf"
value={form.values.pengesahan.stempel}
onChange={(f) =>
form.setFieldValue("pengesahan.stempel", f)
}
/>
</FormField>
</Grid.Col>
</Grid>
</FormSection>
{/* Submit / Reset actions */}
<Group justify="right" gap="sm">
<Button
variant="default"
onClick={handleReset}
leftSection={<IconX size={16} />}
>
Reset
</Button>
<Button
onClick={() => {
const result = form.validate();
if (result.hasErrors) {
setSubmitStatus({
success: false,
message:
"Terdapat kesalahan pada form. Mohon periksa kembali.",
});
// scroll to first error? could enhance later.
return;
}
handleSubmit(form.values);
}}
leftSection={<IconCheck size={16} />}
>
Submit
</Button>
</Group>
{/* Submission feedback */}
{submitStatus ? (
<Notification
color={submitStatus.success ? "teal" : "red"}
icon={submitStatus.success ? <IconCheck /> : <IconX />}
>
{submitStatus.message}
</Notification>
) : null}
</Stack>
</Card>
</Container>
);
}

View File

@@ -1,112 +0,0 @@
import { Button, Card, Container, Group, Stack, Table, Text, TextInput } from "@mantine/core";
import { useEffect, useState } from "react";
import apiFetch from "@/lib/apiFetch";
import { showNotification } from "@mantine/notifications";
export default function ApiKeyPage() {
return (
<Container size="md" w={"100%"}>
<Stack>
<Text>API Key</Text>
<CreateApiKey />
</Stack>
</Container>
);
}
function CreateApiKey() {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [expiredAt, setExpiredAt] = useState('');
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
const res = await apiFetch.api.apikey.create.post({ name, description, expiredAt });
if (res.status === 200) {
setName('');
setDescription('');
setExpiredAt('');
showNotification({
title: 'Success',
message: 'API key created successfully',
color: 'green',
})
}
setLoading(false);
}
return (
<Card >
<Stack>
<Text>API Create</Text>
<TextInput label="Name" placeholder="Name" value={name} onChange={(e) => setName(e.target.value)} />
<TextInput label="Description" placeholder="Description" value={description} onChange={(e) => setDescription(e.target.value)} />
<TextInput label="Expired At" placeholder="Expired At" type="date" value={expiredAt} onChange={(e) => setExpiredAt(e.target.value)} />
<Group>
<Button variant="outline" onClick={() => { setName(''); setDescription(''); setExpiredAt(''); }}>Cancel</Button>
<Button onClick={handleSubmit} type="submit" loading={loading}>Save</Button>
</Group>
<ListApiKey />
</Stack>
</Card>
);
}
function ListApiKey() {
const [apiKeys, setApiKeys] = useState<any[]>([]);
useEffect(() => {
const fetchApiKeys = async () => {
const res = await apiFetch.api.apikey.list.get();
if (res.status === 200) {
setApiKeys(res.data?.apiKeys || []);
}
}
fetchApiKeys();
}, []);
return (
<Card>
<Stack>
<Text>API List</Text>
<Table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Expired At</th>
<th>Created At</th>
<th>Updated At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{apiKeys.map((apiKey: any, index: number) => (
<tr key={index}>
<td>{apiKey.name}</td>
<td>{apiKey.description}</td>
<td>{apiKey.expiredAt.toISOString().split('T')[0]}</td>
<td>{apiKey.createdAt.toISOString().split('T')[0]}</td>
<td>{apiKey.updatedAt.toISOString().split('T')[0]}</td>
<td>
<Button variant="outline" onClick={() => {
apiFetch.api.apikey.delete.delete({ id: apiKey.id })
setApiKeys(apiKeys.filter((api: any) => api.id !== apiKey.id))
}}>Delete</Button>
<Button variant="outline" onClick={() => {
navigator.clipboard.writeText(apiKey.key)
showNotification({
title: 'Success',
message: 'API key copied to clipboard',
color: 'green',
})
}}>Copy</Button>
</td>
</tr>
))}
</tbody>
</Table>
</Stack>
</Card>
);
}

View File

@@ -1,89 +0,0 @@
import apiFetch from "@/lib/apiFetch";
import { Button, Card, Container, Flex, Group, Paper, Stack, Text, TextInput, Title } from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks";
import { showNotification } from "@mantine/notifications";
import { useState } from "react";
import useSwr from 'swr'
import { proxy, subscribe, useSnapshot } from 'valtio'
const state = proxy({
reload: ""
})
function reloadState() {
state.reload = Math.random().toString()
}
export default function CredentialPage() {
return <Container size={"md"} w={"100%"}>
<Stack>
<CredentialCreate />
<CredentialList />
</Stack>
</Container>
}
function CredentialCreate() {
const [name, setName] = useState("")
const [apikey, setApikey] = useState("")
async function handleSubmit() {
const { data } = await apiFetch.api.credential.create.post({
name: name,
value: apikey
})
setName("")
setApikey("")
showNotification({
message: data?.message
})
reloadState()
}
return <Card>
<Stack>
<Title>Credential Create</Title>
<TextInput placeholder="name" value={name} onChange={(e) => setName(e.target.value)} />
<TextInput placeholder="apikey" value={apikey} onChange={(e) => setApikey(e.target.value)} />
<Group>
<Button onClick={handleSubmit}>Save</Button>
</Group>
</Stack>
</Card>
}
function CredentialList() {
const { data, mutate } = useSwr("/", () => apiFetch.api.credential.list.get())
useShallowEffect(() => {
const unsubscribe = subscribe(state, async () => {
console.log('state has changed to', state)
mutate()
})
return () => unsubscribe()
}, [])
async function handleRm(id: string) {
await apiFetch.api.credential.rm.delete({
id: id
})
reloadState()
}
return <Card>
<Stack>
{data?.data?.list.map((v, k) => <Stack key={k}>
<Flex justify={"space-between"}>
<Text>{v.name}</Text>
<Group>
<Button onClick={() => handleRm(v.id)}>delete</Button>
</Group>
</Flex>
</Stack>)}
</Stack>
</Card>
}

View File

@@ -1,189 +0,0 @@
import { useEffect, useState } from 'react'
import {
ActionIcon,
AppShell,
Avatar,
Button,
Card,
Divider,
Flex,
Group,
NavLink,
Paper,
ScrollArea,
Stack,
Text,
Title,
Tooltip
} from '@mantine/core'
import { useLocalStorage } from '@mantine/hooks'
import {
IconChevronLeft,
IconChevronRight,
IconDashboard,
IconKey,
IconLock
} from '@tabler/icons-react'
import type { User } from 'generated/prisma'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import { default as clientRoute, default as clientRoutes } from '@/clientRoutes'
import apiFetch from '@/lib/apiFetch'
function Logout() {
return <Group>
<Button variant='transparent' size='compact-xs' onClick={async () => {
await apiFetch.auth.logout.delete()
localStorage.removeItem('token')
window.location.href = '/login'
}}>Logout</Button>
</Group>
}
export default function DashboardLayout() {
const [opened, setOpened] = useLocalStorage({
key: 'nav_open',
defaultValue: true,
})
return (
<AppShell
padding="md"
navbar={{
width: 260,
breakpoint: 'sm',
collapsed: { mobile: !opened, desktop: !opened },
}}
>
<AppShell.Navbar>
<AppShell.Section>
<Group justify="flex-end" p="xs">
<Tooltip
label={opened ? 'Collapse navigation' : 'Expand navigation'}
withArrow
>
<ActionIcon
variant="light"
color="gray"
onClick={() => setOpened(v => !v)}
aria-label="Toggle navigation"
radius="xl"
>
{opened ? <IconChevronLeft /> : <IconChevronRight />}
</ActionIcon>
</Tooltip>
</Group>
</AppShell.Section>
<AppShell.Section grow component={ScrollArea} flex={1}>
<NavigationDashboard />
</AppShell.Section>
<AppShell.Section>
<HostView />
</AppShell.Section>
</AppShell.Navbar>
<AppShell.Main>
<Stack>
<Paper withBorder shadow="md" radius="lg" p="md">
<Flex align="center" gap="md">
{!opened && (
<Tooltip label="Open navigation menu" withArrow>
<ActionIcon
variant="light"
color="gray"
onClick={() => setOpened(true)}
aria-label="Open navigation"
radius="xl"
>
<IconChevronRight />
</ActionIcon>
</Tooltip>
)}
<Title order={3} fw={600}>
App Dashboard
</Title>
</Flex>
</Paper>
<Outlet />
</Stack>
</AppShell.Main>
</AppShell>
)
}
/* ----------------------- Host Info ----------------------- */
function HostView() {
const [host, setHost] = useState<User | null>(null)
useEffect(() => {
async function fetchHost() {
const { data } = await apiFetch.api.user.find.get()
setHost(data?.user ?? null)
}
fetchHost()
}, [])
return (
<Card radius="lg" withBorder shadow="sm" p="md">
{host ? (
<Stack>
<Flex gap="md" align="center">
<Avatar size="md" radius="xl" color="blue">
{host.name?.[0]}
</Avatar>
<Stack gap={2}>
<Text fw={600}>{host.name}</Text>
<Text size="sm" c="dimmed">{host.email}</Text>
</Stack>
</Flex>
<Divider />
<Logout />
</Stack>
) : (
<Text size="sm" c="dimmed" ta="center">
No host information available
</Text>
)}
</Card>
)
}
/* ----------------------- Navigation ----------------------- */
function NavigationDashboard() {
const navigate = useNavigate()
const location = useLocation()
const isActive = (path: keyof typeof clientRoute) =>
location.pathname.startsWith(clientRoute[path])
return (
<Stack gap="xs" p="sm">
<NavLink
active={isActive('/dashboard/landing')}
leftSection={<IconDashboard size={20} />}
label="Dashboard Overview"
description="Quick summary and activity highlights"
onClick={() => navigate(clientRoutes['/dashboard/landing'])}
/>
<NavLink
active={isActive('/dashboard/apikey')}
leftSection={<IconKey size={20} />}
label="Dashboard Overview"
description="Quick summary and activity highlights"
onClick={() => navigate(clientRoutes['/dashboard/apikey'])}
/>
<NavLink
active={isActive('/dashboard/credential')}
leftSection={<IconLock size={20} />}
label="Dashboard Overview"
description="Quick summary and activity highlights"
onClick={() => navigate(clientRoutes['/dashboard/credential'])}
/>
</Stack>
)
}

View File

@@ -1,11 +0,0 @@
import apiFetch from "@/lib/apiFetch";
import { Button } from "@mantine/core";
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
</div>
);
}

View File

@@ -0,0 +1,164 @@
import {
Button,
Card,
Container,
Group,
Stack,
Table,
Text,
TextInput,
} from "@mantine/core";
import { useEffect, useState } from "react";
import apiFetch from "@/lib/apiFetch";
import { showNotification } from "@mantine/notifications";
export default function ApiKeyPage() {
return (
<Container size="md" w={"100%"}>
<Stack>
<Text>API Key</Text>
<CreateApiKey />
</Stack>
</Container>
);
}
function CreateApiKey() {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [expiredAt, setExpiredAt] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
const res = await apiFetch.api.apikey.create.post({
name,
description,
expiredAt,
});
if (res.status === 200) {
setName("");
setDescription("");
setExpiredAt("");
showNotification({
title: "Success",
message: "API key created successfully",
color: "green",
});
}
setLoading(false);
};
return (
<Card>
<Stack>
<Text>API Create</Text>
<TextInput
label="Name"
placeholder="Name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<TextInput
label="Description"
placeholder="Description"
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<TextInput
label="Expired At"
placeholder="Expired At"
type="date"
value={expiredAt}
onChange={(e) => setExpiredAt(e.target.value)}
/>
<Group>
<Button
variant="outline"
onClick={() => {
setName("");
setDescription("");
setExpiredAt("");
}}
>
Cancel
</Button>
<Button onClick={handleSubmit} type="submit" loading={loading}>
Save
</Button>
</Group>
<ListApiKey />
</Stack>
</Card>
);
}
function ListApiKey() {
const [apiKeys, setApiKeys] = useState<any[]>([]);
useEffect(() => {
const fetchApiKeys = async () => {
const res = await apiFetch.api.apikey.list.get();
if (res.status === 200) {
setApiKeys(res.data?.apiKeys || []);
}
};
fetchApiKeys();
}, []);
return (
<Card>
<Stack>
<Text>API List</Text>
<Table>
<thead>
<tr>
<th>Name</th>
<th>Description</th>
<th>Expired At</th>
<th>Created At</th>
<th>Updated At</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{apiKeys.map((apiKey: any, index: number) => (
<tr key={index}>
<td>{apiKey.name}</td>
<td>{apiKey.description}</td>
<td>{apiKey.expiredAt.toISOString().split("T")[0]}</td>
<td>{apiKey.createdAt.toISOString().split("T")[0]}</td>
<td>{apiKey.updatedAt.toISOString().split("T")[0]}</td>
<td>
<Button
variant="outline"
onClick={() => {
apiFetch.api.apikey.delete.delete({ id: apiKey.id });
setApiKeys(
apiKeys.filter((api: any) => api.id !== apiKey.id),
);
}}
>
Delete
</Button>
<Button
variant="outline"
onClick={() => {
navigator.clipboard.writeText(apiKey.key);
showNotification({
title: "Success",
message: "API key copied to clipboard",
color: "green",
});
}}
>
Copy
</Button>
</td>
</tr>
))}
</tbody>
</Table>
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,116 @@
import apiFetch from "@/lib/apiFetch";
import {
Button,
Card,
Container,
Flex,
Group,
Stack,
Text,
TextInput,
Title,
} from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks";
import { showNotification } from "@mantine/notifications";
import { useState } from "react";
import useSwr from "swr";
import { proxy, subscribe } from "valtio";
const state = proxy({
reload: "",
});
function reloadState() {
state.reload = Math.random().toString();
}
export default function CredentialPage() {
return (
<Container size={"md"} w={"100%"}>
<Stack>
<CredentialCreate />
<CredentialList />
</Stack>
</Container>
);
}
function CredentialCreate() {
const [name, setName] = useState("");
const [apikey, setApikey] = useState("");
async function handleSubmit() {
const { data } = await apiFetch.api.credential.create.post({
name: name,
value: apikey,
});
setName("");
setApikey("");
showNotification({
message: data?.message,
});
reloadState();
}
return (
<Card>
<Stack>
<Title>Credential Create</Title>
<TextInput
placeholder="name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<TextInput
placeholder="apikey"
value={apikey}
onChange={(e) => setApikey(e.target.value)}
/>
<Group>
<Button onClick={handleSubmit}>Save</Button>
</Group>
</Stack>
</Card>
);
}
function CredentialList() {
const { data, mutate } = useSwr("/", () =>
apiFetch.api.credential.list.get(),
);
useShallowEffect(() => {
const unsubscribe = subscribe(state, async () => {
console.log("state has changed to", state);
mutate();
});
return () => unsubscribe();
}, []);
async function handleRm(id: string) {
await apiFetch.api.credential.rm.delete({
id: id,
});
reloadState();
}
return (
<Card>
<Stack>
{data?.data?.list.map((v, k) => (
<Stack key={k}>
<Flex justify={"space-between"}>
<Text>{v.name}</Text>
<Group>
<Button onClick={() => handleRm(v.id)}>delete</Button>
</Group>
</Flex>
</Stack>
))}
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,7 @@
export default function Dashboard() {
return (
<div>
<h1>Dashboard</h1>
</div>
);
}

View File

@@ -0,0 +1,202 @@
import { useEffect, useState } from "react";
import {
ActionIcon,
AppShell,
Avatar,
Button,
Card,
Divider,
Flex,
Group,
NavLink,
Paper,
ScrollArea,
Stack,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { useLocalStorage } from "@mantine/hooks";
import {
IconChevronLeft,
IconChevronRight,
IconDashboard,
IconKey,
IconLock,
} from "@tabler/icons-react";
import type { User } from "generated/prisma";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
import {
default as clientRoute,
default as clientRoutes,
} from "@/clientRoutes";
import apiFetch from "@/lib/apiFetch";
function Logout() {
return (
<Group>
<Button
variant="transparent"
size="compact-xs"
onClick={async () => {
await apiFetch.auth.logout.delete();
localStorage.removeItem("token");
window.location.href = "/login";
}}
>
Logout
</Button>
</Group>
);
}
export default function DashboardLayout() {
const [opened, setOpened] = useLocalStorage({
key: "nav_open",
defaultValue: true,
});
return (
<AppShell
padding="md"
navbar={{
width: 260,
breakpoint: "sm",
collapsed: { mobile: !opened, desktop: !opened },
}}
>
<AppShell.Navbar>
<AppShell.Section>
<Group justify="flex-end" p="xs">
<Tooltip
label={opened ? "Collapse navigation" : "Expand navigation"}
withArrow
>
<ActionIcon
variant="light"
color="gray"
onClick={() => setOpened((v) => !v)}
aria-label="Toggle navigation"
radius="xl"
>
{opened ? <IconChevronLeft /> : <IconChevronRight />}
</ActionIcon>
</Tooltip>
</Group>
</AppShell.Section>
<AppShell.Section grow component={ScrollArea} flex={1}>
<NavigationDashboard />
</AppShell.Section>
<AppShell.Section>
<HostView />
</AppShell.Section>
</AppShell.Navbar>
<AppShell.Main>
<Stack>
<Paper withBorder shadow="md" radius="lg" p="md">
<Flex align="center" gap="md">
{!opened && (
<Tooltip label="Open navigation menu" withArrow>
<ActionIcon
variant="light"
color="gray"
onClick={() => setOpened(true)}
aria-label="Open navigation"
radius="xl"
>
<IconChevronRight />
</ActionIcon>
</Tooltip>
)}
<Title order={3} fw={600}>
App Dashboard
</Title>
</Flex>
</Paper>
<Outlet />
</Stack>
</AppShell.Main>
</AppShell>
);
}
/* ----------------------- Host Info ----------------------- */
function HostView() {
const [host, setHost] = useState<User | null>(null);
useEffect(() => {
async function fetchHost() {
const { data } = await apiFetch.api.user.find.get();
setHost(data?.user ?? null);
}
fetchHost();
}, []);
return (
<Card radius="lg" withBorder shadow="sm" p="md">
{host ? (
<Stack>
<Flex gap="md" align="center">
<Avatar size="md" radius="xl" color="blue">
{host.name?.[0]}
</Avatar>
<Stack gap={2}>
<Text fw={600}>{host.name}</Text>
<Text size="sm" c="dimmed">
{host.email}
</Text>
</Stack>
</Flex>
<Divider />
<Logout />
</Stack>
) : (
<Text size="sm" c="dimmed" ta="center">
No host information available
</Text>
)}
</Card>
);
}
/* ----------------------- Navigation ----------------------- */
function NavigationDashboard() {
const navigate = useNavigate();
const location = useLocation();
const isActive = (path: keyof typeof clientRoute) =>
location.pathname.startsWith(clientRoute[path]);
return (
<Stack gap="xs" p="sm">
<NavLink
active={isActive("/scr/dashboard/dashboard-home")}
leftSection={<IconDashboard size={20} />}
label="Dashboard Overview"
description="Quick summary and activity highlights"
onClick={() => navigate(clientRoutes["/scr/dashboard/dashboard-home"])}
/>
<NavLink
active={isActive("/scr/dashboard/apikey/apikey")}
leftSection={<IconKey size={20} />}
label="Dashboard Overview"
description="Quick summary and activity highlights"
onClick={() => navigate(clientRoutes["/scr/dashboard/apikey/apikey"])}
/>
<NavLink
active={isActive("/scr/dashboard/credential/credential")}
leftSection={<IconLock size={20} />}
label="Dashboard Overview"
description="Quick summary and activity highlights"
onClick={() =>
navigate(clientRoutes["/scr/dashboard/credential/credential"])
}
/>
</Stack>
);
}

View File

@@ -0,0 +1,25 @@
import { useEffect, useState } from "react";
import { Navigate, Outlet } from "react-router-dom";
import clientRoutes from "@/clientRoutes";
import apiFetch from "@/lib/apiFetch";
export default function ProtectedRoute() {
const [isAuthenticated, setIsAuthenticated] = useState<boolean | null>(null);
useEffect(() => {
async function checkSession() {
try {
// backend otomatis baca cookie JWT dari request
const res = await apiFetch.api.user.find.get();
setIsAuthenticated(res.status === 200);
} catch {
setIsAuthenticated(false);
}
}
checkSession();
}, []);
if (isAuthenticated === null) return null; // or loading spinner
if (!isAuthenticated) return <Navigate to={clientRoutes["/login"]} replace />;
return <Outlet />;
}