tambahan
This commit is contained in:
23
src/App.tsx
23
src/App.tsx
@@ -1,15 +1,16 @@
|
||||
import "@mantine/core/styles.css";
|
||||
import "@mantine/notifications/styles.css";
|
||||
import '@mantine/dates/styles.css'
|
||||
import { Notifications } from "@mantine/notifications";
|
||||
|
||||
import '@mantine/core/styles.css';
|
||||
import '@mantine/notifications/styles.css';
|
||||
import { Notifications } from '@mantine/notifications';
|
||||
|
||||
import { MantineProvider } from '@mantine/core';
|
||||
import AppRoutes from './AppRoutes';
|
||||
import { MantineProvider } from "@mantine/core";
|
||||
import AppRoutes from "./AppRoutes";
|
||||
|
||||
export function App() {
|
||||
return <MantineProvider defaultColorScheme='dark'>
|
||||
<Notifications />
|
||||
<AppRoutes />
|
||||
</MantineProvider>;
|
||||
return (
|
||||
<MantineProvider defaultColorScheme="dark">
|
||||
<Notifications />
|
||||
<AppRoutes />
|
||||
</MantineProvider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,34 +1,104 @@
|
||||
|
||||
// ⚡ Auto-generated by generateRoutes.ts — DO NOT EDIT MANUALLY
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import Home from "./pages/Home";
|
||||
import NotFound from "./pages/NotFound";
|
||||
import Login from "./pages/Login";
|
||||
import ProtectedRoute from "./components/ProtectedRoute";
|
||||
import Dashboard from "./pages/dashboard/dashboard_page";
|
||||
import DashboardLayout from "./pages/dashboard/dashboard_layout";
|
||||
import ApiKeyPage from "./pages/dashboard/apikey/apikey_page";
|
||||
import CredentialPage from "./pages/dashboard/credential/credential_page";
|
||||
import DarmasabaLayout from "./pages/darmasaba/darmasaba_layout";
|
||||
import FormSuratKeteranganUsaha from "./pages/darmasaba/form_surat_keterangan_usaha";
|
||||
import FormSuratKeteranganTidakMampu from "./pages/darmasaba/form_surat_keterangan_tidak_mampu";
|
||||
import DarmasabaHome from "./pages/darmasaba/darmasaba_home";
|
||||
import FormKartuTandaPenduduk from "./pages/darmasaba/form_kartu_tanda_penduduk";
|
||||
import FormKartuKeluarga from "./pages/darmasaba/form_kartu_keluarga";
|
||||
import FormLaporanSampah from "./pages/darmasaba/form_laporan_sampah";
|
||||
import FormSuratKeteranganPenghasilan from "./pages/darmasaba/form_surat_keterangan_penghasilan";
|
||||
import FormSuratKeteranganDomisiliOrganisasi from "./pages/darmasaba/form_surat_keterangan_domisili_organisasi";
|
||||
import FormSuratKeteranganBelumKawin from "./pages/darmasaba/form_surat_keterangan_belum_kawin";
|
||||
import FormKeteranganKelahiran from "./pages/darmasaba/form_keterangan_kelahiran";
|
||||
import FormSuratKeteranganTempatUsaha from "./pages/darmasaba/form_surat_keterangan_tempat_usaha";
|
||||
import FormSuratKeteranganKelakuanBaik from "./pages/darmasaba/form_surat_keterangan_kelakuan_baik";
|
||||
import Home from "./pages/Home";
|
||||
import CredentialPage from "./pages/scr/dashboard/credential/credential_page";
|
||||
import DashboardHome from "./pages/scr/dashboard/dashboard_home";
|
||||
import ApikeyPage from "./pages/scr/dashboard/apikey/apikey_page";
|
||||
import DashboardLayout from "./pages/scr/dashboard/dashboard_layout";
|
||||
import ScrLayout from "./pages/scr/scr_layout";
|
||||
import NotFound from "./pages/NotFound";
|
||||
|
||||
export default function AppRoutes() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/dashboard" element={<DashboardLayout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="landing" element={<Dashboard />} />
|
||||
<Route path="apikey" element={<ApiKeyPage />} />
|
||||
<Route path="credential" element={<CredentialPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
<Route path="/darmasaba" element={<DarmasabaLayout />}>
|
||||
<Route index element={<DarmasabaHome />} />
|
||||
|
||||
<Route
|
||||
path="/darmasaba/surat-keterangan-usaha"
|
||||
element={<FormSuratKeteranganUsaha />}
|
||||
/>
|
||||
<Route
|
||||
path="/darmasaba/surat-keterangan-tidak-mampu"
|
||||
element={<FormSuratKeteranganTidakMampu />}
|
||||
/>
|
||||
<Route path="/darmasaba/darmasaba-home" element={<DarmasabaHome />} />
|
||||
<Route
|
||||
path="/darmasaba/kartu-tanda-penduduk"
|
||||
element={<FormKartuTandaPenduduk />}
|
||||
/>
|
||||
<Route
|
||||
path="/darmasaba/kartu-keluarga"
|
||||
element={<FormKartuKeluarga />}
|
||||
/>
|
||||
<Route
|
||||
path="/darmasaba/laporan-sampah"
|
||||
element={<FormLaporanSampah />}
|
||||
/>
|
||||
<Route
|
||||
path="/darmasaba/surat-keterangan-penghasilan"
|
||||
element={<FormSuratKeteranganPenghasilan />}
|
||||
/>
|
||||
<Route
|
||||
path="/darmasaba/surat-keterangan-domisili-organisasi"
|
||||
element={<FormSuratKeteranganDomisiliOrganisasi />}
|
||||
/>
|
||||
<Route
|
||||
path="/darmasaba/surat-keterangan-belum-kawin"
|
||||
element={<FormSuratKeteranganBelumKawin />}
|
||||
/>
|
||||
<Route
|
||||
path="/darmasaba/keterangan-kelahiran"
|
||||
element={<FormKeteranganKelahiran />}
|
||||
/>
|
||||
<Route
|
||||
path="/darmasaba/surat-keterangan-tempat-usaha"
|
||||
element={<FormSuratKeteranganTempatUsaha />}
|
||||
/>
|
||||
<Route
|
||||
path="/darmasaba/surat-keterangan-kelakuan-baik"
|
||||
element={<FormSuratKeteranganKelakuanBaik />}
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/" element={<Home />} />
|
||||
|
||||
<Route path="/scr" element={<ScrLayout />}>
|
||||
<Route path="/scr/dashboard" element={<DashboardLayout />}>
|
||||
<Route index element={<DashboardHome />} />
|
||||
|
||||
<Route
|
||||
path="/scr/dashboard/credential/credential"
|
||||
element={<CredentialPage />}
|
||||
/>
|
||||
<Route
|
||||
path="/scr/dashboard/dashboard-home"
|
||||
element={<DashboardHome />}
|
||||
/>
|
||||
<Route
|
||||
path="/scr/dashboard/apikey/apikey"
|
||||
element={<ApikeyPage />}
|
||||
/>
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="/*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
34
src/AppRoutes.txt
Normal file
34
src/AppRoutes.txt
Normal file
@@ -0,0 +1,34 @@
|
||||
|
||||
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
||||
import Home from "./pages/Home";
|
||||
import NotFound from "./pages/NotFound";
|
||||
import Login from "./pages/Login";
|
||||
import ProtectedRoute from "./components/ProtectedRoute";
|
||||
import Dashboard from "./pages/dashboard/dashboard_page";
|
||||
import DashboardLayout from "./pages/dashboard/dashboard_layout";
|
||||
import ApiKeyPage from "./pages/dashboard/apikey/apikey_page";
|
||||
import CredentialPage from "./pages/dashboard/credential/credential_page";
|
||||
|
||||
export default function AppRoutes() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Routes>
|
||||
<Route path="/" element={<Home />} />
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/dashboard" element={<DashboardLayout />}>
|
||||
<Route index element={<Dashboard />} />
|
||||
<Route path="landing" element={<Dashboard />} />
|
||||
<Route path="apikey" element={<ApiKeyPage />} />
|
||||
<Route path="credential" element={<CredentialPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
<Route path="*" element={<NotFound />} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1,11 +1,25 @@
|
||||
// AUTO-GENERATED FILE
|
||||
const clientRoutes = {
|
||||
"/": "/",
|
||||
"/login": "/login",
|
||||
"/dashboard": "/dashboard",
|
||||
"/dashboard/landing": "/dashboard/landing",
|
||||
"/dashboard/apikey": "/dashboard/apikey",
|
||||
"/dashboard/credential": "/dashboard/credential",
|
||||
"/darmasaba": "/darmasaba",
|
||||
"/darmasaba/surat-keterangan-usaha": "/darmasaba/surat-keterangan-usaha",
|
||||
"/darmasaba/surat-keterangan-tidak-mampu": "/darmasaba/surat-keterangan-tidak-mampu",
|
||||
"/darmasaba/darmasaba-home": "/darmasaba/darmasaba-home",
|
||||
"/darmasaba/kartu-tanda-penduduk": "/darmasaba/kartu-tanda-penduduk",
|
||||
"/darmasaba/kartu-keluarga": "/darmasaba/kartu-keluarga",
|
||||
"/darmasaba/laporan-sampah": "/darmasaba/laporan-sampah",
|
||||
"/darmasaba/surat-keterangan-penghasilan": "/darmasaba/surat-keterangan-penghasilan",
|
||||
"/darmasaba/surat-keterangan-domisili-organisasi": "/darmasaba/surat-keterangan-domisili-organisasi",
|
||||
"/darmasaba/surat-keterangan-belum-kawin": "/darmasaba/surat-keterangan-belum-kawin",
|
||||
"/darmasaba/keterangan-kelahiran": "/darmasaba/keterangan-kelahiran",
|
||||
"/darmasaba/surat-keterangan-tempat-usaha": "/darmasaba/surat-keterangan-tempat-usaha",
|
||||
"/darmasaba/surat-keterangan-kelakuan-baik": "/darmasaba/surat-keterangan-kelakuan-baik",
|
||||
"/": "/",
|
||||
"/scr": "/scr",
|
||||
"/scr/dashboard": "/scr/dashboard",
|
||||
"/scr/dashboard/credential/credential": "/scr/dashboard/credential/credential",
|
||||
"/scr/dashboard/dashboard-home": "/scr/dashboard/dashboard-home",
|
||||
"/scr/dashboard/apikey/apikey": "/scr/dashboard/apikey/apikey",
|
||||
"/*": "/*"
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -1,25 +1,25 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Navigate, Outlet } from 'react-router-dom'
|
||||
import clientRoutes from '@/clientRoutes'
|
||||
import apiFetch from '@/lib/apiFetch'
|
||||
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)
|
||||
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)
|
||||
const res = await apiFetch.api.user.find.get();
|
||||
setIsAuthenticated(res.status === 200);
|
||||
} catch {
|
||||
setIsAuthenticated(false)
|
||||
setIsAuthenticated(false);
|
||||
}
|
||||
}
|
||||
checkSession()
|
||||
}, [])
|
||||
checkSession();
|
||||
}, []);
|
||||
|
||||
if (isAuthenticated === null) return null // or loading spinner
|
||||
if (!isAuthenticated) return <Navigate to={clientRoutes['/login']} replace />
|
||||
return <Outlet />
|
||||
if (isAuthenticated === null) return null; // or loading spinner
|
||||
if (!isAuthenticated) return <Navigate to={clientRoutes["/login"]} replace />;
|
||||
return <Outlet />;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
|
||||
import Swagger from "@elysiajs/swagger";
|
||||
import Elysia from "elysia";
|
||||
import type { User } from "generated/prisma";
|
||||
import html from "./index.html";
|
||||
import apiAuth from "./server/middlewares/apiAuth";
|
||||
import ApiKeyRoute from "./server/routes/apikey_route";
|
||||
@@ -9,55 +7,49 @@ import Auth from "./server/routes/auth_route";
|
||||
import CredentialRoute from "./server/routes/credential_route";
|
||||
import DarmasabaRoute from "./server/routes/darmasaba_route";
|
||||
import { convertOpenApiToMcp } from "./server/lib/mcp-converter";
|
||||
import UserRoute from "./server/routes/user_route";
|
||||
import LayananRoute from "./server/routes/layanan_route";
|
||||
|
||||
const Docs = new Elysia()
|
||||
.use(Swagger({
|
||||
const Docs = new Elysia({
|
||||
tags: ["docs"],
|
||||
}).use(
|
||||
Swagger({
|
||||
path: "/docs",
|
||||
}))
|
||||
|
||||
const ApiUser = new Elysia({
|
||||
prefix: "/user",
|
||||
})
|
||||
.get('/find', (ctx) => {
|
||||
const { user } = ctx as any
|
||||
return {
|
||||
user: user as User
|
||||
}
|
||||
},{
|
||||
detail: {
|
||||
summary: "find",
|
||||
description: "find user",
|
||||
}
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
const Api = new Elysia({
|
||||
prefix: "/api",
|
||||
tags: ["api"],
|
||||
})
|
||||
.use(apiAuth)
|
||||
.use(ApiKeyRoute)
|
||||
.use(DarmasabaRoute)
|
||||
.use(ApiUser)
|
||||
.use(CredentialRoute)
|
||||
.use(UserRoute)
|
||||
.use(LayananRoute);
|
||||
|
||||
const app = new Elysia()
|
||||
.use(Api)
|
||||
.use(Docs)
|
||||
.use(Auth)
|
||||
.get("/.well-known/mcp.json", async () => {
|
||||
const baseUrl = process.env.BUN_PUBLIC_BASE_URL!
|
||||
return await convertOpenApiToMcp(baseUrl)
|
||||
}, {
|
||||
detail: {
|
||||
description: "MCP manifest",
|
||||
tags: ["MCP"],
|
||||
}
|
||||
})
|
||||
.get(
|
||||
"/.well-known/mcp.json",
|
||||
async () => {
|
||||
const baseUrl = process.env.BUN_PUBLIC_BASE_URL!;
|
||||
return await convertOpenApiToMcp(baseUrl);
|
||||
},
|
||||
{
|
||||
detail: {
|
||||
description: "MCP manifest",
|
||||
tags: ["MCP"],
|
||||
},
|
||||
},
|
||||
)
|
||||
// .use(McpRoute)
|
||||
.get("*", html)
|
||||
.listen(3000, () => {
|
||||
console.log("Server running at http://localhost:3000");
|
||||
});
|
||||
|
||||
|
||||
export type ServerApp = typeof app;
|
||||
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Home</h1>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<h1>Home</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
|
||||
export default function NotFound() {
|
||||
return (
|
||||
<div>
|
||||
<h1>404 Not Found</h1>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<div>
|
||||
<h1>404 Not Found</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
82
src/pages/darmasaba/darmasaba_home.tsx
Normal file
82
src/pages/darmasaba/darmasaba_home.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
5
src/pages/darmasaba/darmasaba_layout.tsx
Normal file
5
src/pages/darmasaba/darmasaba_layout.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Outlet } from "react-router-dom";
|
||||
|
||||
export default function DarmasabaLayout() {
|
||||
return <Outlet />;
|
||||
}
|
||||
835
src/pages/darmasaba/form_kartu_keluarga.tsx
Normal file
835
src/pages/darmasaba/form_kartu_keluarga.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
641
src/pages/darmasaba/form_kartu_tanda_penduduk.tsx
Normal file
641
src/pages/darmasaba/form_kartu_tanda_penduduk.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
945
src/pages/darmasaba/form_keterangan_kelahiran.tsx
Normal file
945
src/pages/darmasaba/form_keterangan_kelahiran.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
649
src/pages/darmasaba/form_laporan_sampah.tsx
Normal file
649
src/pages/darmasaba/form_laporan_sampah.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
549
src/pages/darmasaba/form_surat_keterangan_belum_kawin.tsx
Normal file
549
src/pages/darmasaba/form_surat_keterangan_belum_kawin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
531
src/pages/darmasaba/form_surat_keterangan_kelakuan_baik.tsx
Normal file
531
src/pages/darmasaba/form_surat_keterangan_kelakuan_baik.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
622
src/pages/darmasaba/form_surat_keterangan_penghasilan.tsx
Normal file
622
src/pages/darmasaba/form_surat_keterangan_penghasilan.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
502
src/pages/darmasaba/form_surat_keterangan_tempat_usaha.tsx
Normal file
502
src/pages/darmasaba/form_surat_keterangan_tempat_usaha.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
600
src/pages/darmasaba/form_surat_keterangan_tidak_mampu.tsx
Normal file
600
src/pages/darmasaba/form_surat_keterangan_tidak_mampu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
641
src/pages/darmasaba/form_surat_keterangan_usaha.tsx
Normal file
641
src/pages/darmasaba/form_surat_keterangan_usaha.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import apiFetch from "@/lib/apiFetch";
|
||||
import { Button } from "@mantine/core";
|
||||
|
||||
export default function Dashboard() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
164
src/pages/scr/dashboard/apikey/apikey_page.tsx
Normal file
164
src/pages/scr/dashboard/apikey/apikey_page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
src/pages/scr/dashboard/credential/credential_page.tsx
Normal file
116
src/pages/scr/dashboard/credential/credential_page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
7
src/pages/scr/dashboard/dashboard_home.tsx
Normal file
7
src/pages/scr/dashboard/dashboard_home.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export default function Dashboard() {
|
||||
return (
|
||||
<div>
|
||||
<h1>Dashboard</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
202
src/pages/scr/dashboard/dashboard_layout.tsx
Normal file
202
src/pages/scr/dashboard/dashboard_layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
src/pages/scr/scr_layout.tsx
Normal file
25
src/pages/scr/scr_layout.tsx
Normal 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 />;
|
||||
}
|
||||
@@ -50,7 +50,7 @@ export async function convertOpenApiToMcp(baseUrl: string): Promise<McpManifest>
|
||||
for (const [method, def] of Object.entries<any>(methods)) {
|
||||
const tags = def.tags || ["default"]
|
||||
const tag = tags[0]
|
||||
const operationId = def.operationId || `${method}_${path.replace(/[\/{}]/g, "_")}`
|
||||
const operationId = def.operationId || `${method}_${path.replace(/\//g, "_")}`
|
||||
|
||||
manifest.capabilities[tag] ??= {}
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ type JWT = {
|
||||
|
||||
const ApiKeyRoute = new Elysia({
|
||||
prefix: '/apikey',
|
||||
detail: { tags: ['apikey'] },
|
||||
tags: ["apikey"],
|
||||
})
|
||||
.post(
|
||||
'/create',
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
import { jwt as jwtPlugin, type JWTPayloadSpec } from '@elysiajs/jwt'
|
||||
import Elysia, { t, type Cookie, type HTTPHeaders, type StatusMap } from 'elysia'
|
||||
import { type ElysiaCookie } from 'elysia/cookies'
|
||||
|
||||
import { prisma } from '@/server/lib/prisma'
|
||||
import type { User } from 'generated/prisma'
|
||||
|
||||
const secret = process.env.JWT_SECRET
|
||||
if (!secret) {
|
||||
@@ -109,7 +107,7 @@ async function login({
|
||||
|
||||
const Auth = new Elysia({
|
||||
prefix: '/auth',
|
||||
detail: { description: 'Auth API', summary: 'Auth API', tags: ['auth'] },
|
||||
tags: ["auth"],
|
||||
})
|
||||
.use(
|
||||
jwtPlugin({
|
||||
|
||||
@@ -2,7 +2,8 @@ import Elysia, { t } from "elysia";
|
||||
import { prisma } from "../lib/prisma";
|
||||
|
||||
const CredentialRoute = new Elysia({
|
||||
prefix: "/credential"
|
||||
prefix: "/credential",
|
||||
tags: ["credential"],
|
||||
})
|
||||
.post("/create", async (ctx) => {
|
||||
const { name, value } = ctx.body
|
||||
@@ -26,7 +27,7 @@ const CredentialRoute = new Elysia({
|
||||
description: 'create credential',
|
||||
}
|
||||
})
|
||||
.get("/list", async (ctx) => {
|
||||
.get("/list", async () => {
|
||||
const list = await prisma.credential.findMany()
|
||||
return {
|
||||
message: "success",
|
||||
@@ -40,7 +41,7 @@ const CredentialRoute = new Elysia({
|
||||
})
|
||||
.delete("/rm", async (ctx) => {
|
||||
const { id } = ctx.body
|
||||
const rm = await prisma.credential.delete({
|
||||
await prisma.credential.delete({
|
||||
where: {
|
||||
id: id
|
||||
}
|
||||
|
||||
@@ -5,34 +5,6 @@ const url = "https://cld-dkr-makuro-seafile.wibudev.com/api2"
|
||||
const TOKEN = "fa49bf1774cad2ec89d2882ae2c6ac1f5d7df445"
|
||||
const REPO_ID = "de64ff3c-0081-45f3-a5a6-6c799a098649"
|
||||
|
||||
const pengaduanDesa: string[] = [
|
||||
"Pengaduan Pelayanan Publik Desa",
|
||||
"Pengaduan Bantuan Sosial (Bansos)",
|
||||
"Pengaduan Penyalahgunaan Dana Desa",
|
||||
"Pengaduan Infrastruktur Rusak (jalan, jembatan, saluran air)",
|
||||
"Pengaduan Lingkungan (sampah, pencemaran, banjir)",
|
||||
"Pengaduan Keamanan dan Ketertiban",
|
||||
"Pengaduan Sengketa Tanah Desa",
|
||||
"Pengaduan Ketenagakerjaan (tenaga kerja lokal, proyek desa)",
|
||||
"Pengaduan Disiplin Aparat Desa",
|
||||
"Pengaduan Administrasi Kependudukan (KTP, KK, surat menyurat)",
|
||||
"Pengaduan Layanan Kesehatan Masyarakat",
|
||||
"Pengaduan Pendidikan (sekolah, bantuan siswa miskin)",
|
||||
"Pengaduan Usaha Mikro dan UMKM Desa",
|
||||
"Pengaduan Kegiatan Bumdes",
|
||||
"Pengaduan Pungutan Liar atau Gratifikasi",
|
||||
"Pengaduan Kekerasan Rumah Tangga atau Sosial",
|
||||
"Pengaduan Pelanggaran Adat dan Norma Sosial",
|
||||
"Pengaduan Proyek Pembangunan Tidak Transparan",
|
||||
"Pengaduan Bencana Alam dan Penanganannya",
|
||||
"Pengaduan Diskriminasi atau Ketidakadilan Sosial",
|
||||
"Pengaduan Pelanggaran Hak Tanah Kas Desa",
|
||||
"Pengaduan Penyaluran Air dan Irigasi",
|
||||
"Pengaduan Akses Internet atau Telekomunikasi Desa",
|
||||
"Pengaduan Fasilitas Umum Tidak Layak",
|
||||
"Pengaduan Kegiatan Tidak Berizin di Wilayah Desa"
|
||||
] as const;
|
||||
|
||||
|
||||
const DarmasabaRoute = new Elysia({
|
||||
prefix: "/darmasaba",
|
||||
|
||||
52
src/server/routes/layanan_route.ts
Normal file
52
src/server/routes/layanan_route.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import Elysia, { t } from "elysia";
|
||||
|
||||
const layanan = `
|
||||
KTP
|
||||
Kartu Keluarga
|
||||
surat keterangan domisili
|
||||
surat pengantar nikah
|
||||
akta kelahiran
|
||||
akta kematian
|
||||
surat pindah penduduk
|
||||
surat keterangan usaha
|
||||
surat keterangan tidak mampu
|
||||
surat keterangan waris
|
||||
surat perizinan usaha kecil
|
||||
`
|
||||
|
||||
const LayananRoute = new Elysia({
|
||||
prefix: "layanan",
|
||||
tags: ["layanan"],
|
||||
})
|
||||
.get("/list", () => {
|
||||
return {
|
||||
success: true,
|
||||
data: layanan.split("\n")
|
||||
}
|
||||
}, {
|
||||
detail: {
|
||||
summary: "list",
|
||||
description: "list layanan yang ada",
|
||||
}
|
||||
})
|
||||
.post("create-ktp", () => {
|
||||
return {
|
||||
success: true,
|
||||
data: ""
|
||||
}
|
||||
}, {
|
||||
body: t.Object({
|
||||
jenis: t.Union([
|
||||
t.Literal("ktp"),
|
||||
t.Literal("kk"),
|
||||
]),
|
||||
nama: t.String(),
|
||||
deskripsi: t.String(),
|
||||
}),
|
||||
detail: {
|
||||
summary: "create",
|
||||
description: "create layanan",
|
||||
}
|
||||
})
|
||||
|
||||
export default LayananRoute
|
||||
51
src/server/routes/user_route.ts
Normal file
51
src/server/routes/user_route.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import Elysia, { t } from "elysia";
|
||||
import type { User } from "generated/prisma";
|
||||
import { prisma } from "../lib/prisma";
|
||||
|
||||
const UserRoute = new Elysia({
|
||||
prefix: "user",
|
||||
tags: ["user"],
|
||||
})
|
||||
.get('/find', (ctx) => {
|
||||
const { user } = ctx as any
|
||||
return {
|
||||
user: user as User
|
||||
}
|
||||
}, {
|
||||
detail: {
|
||||
summary: "find",
|
||||
description: "find user",
|
||||
}
|
||||
})
|
||||
.post("/upsert", async (ctx) => {
|
||||
const { name, phone } = ctx.body
|
||||
const upsert = await prisma.user.upsert({
|
||||
where: {
|
||||
phone
|
||||
},
|
||||
update: {
|
||||
name
|
||||
},
|
||||
create: {
|
||||
name,
|
||||
phone
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
upsert
|
||||
}
|
||||
|
||||
}, {
|
||||
body: t.Object({
|
||||
name: t.String({ minLength: 1, error: "name is required" }),
|
||||
phone: t.String({ minLength: 1, error: "phone is required" })
|
||||
}),
|
||||
detail: {
|
||||
summary: "upsert",
|
||||
description: "upsert user",
|
||||
}
|
||||
})
|
||||
|
||||
export default UserRoute
|
||||
Reference in New Issue
Block a user