feat: implement Kependudukan menu with CRUD admin pages

- Add Distribusi Umur admin pages (list, create, edit)
- Add Data Banjar admin pages (list, create, edit)
- Add Migrasi Penduduk admin pages (list, create, edit)
- Update state management with full CRUD operations for all modules
- Add Kependudukan menu to admin sidebar (devBar, navBar, role1)
- Add public pages for Distribusi Umur with age range sorting
- Update Dinamika Penduduk to use real-time birth/death data
- Add Biome configuration for code linting
- Create API routes for all Kependudukan modules

Features:
- Pagination and search for all admin list pages
- Responsive design (table for desktop, cards for mobile)
- Delete confirmation modal
- Toast notifications for user feedback
- Zod validation for all forms
- Age range auto-sorting in public Distribusi Umur chart

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
2026-04-09 17:10:29 +08:00
parent 34a37dc63b
commit 5e822f0b05
51 changed files with 5964 additions and 0 deletions

View File

@@ -0,0 +1,27 @@
import ApiFetch from "@/lib/api-fetch";
import { proxy } from "valtio";
const kependudukanDashboard = proxy({
summary: {
data: null as any,
loading: false,
async load() {
kependudukanDashboard.summary.loading = true;
try {
const res = await ApiFetch.api.kependudukan.dashboard.summary.get();
if (res.status === 200 && res.data?.success) {
kependudukanDashboard.summary.data = res.data.data;
} else {
kependudukanDashboard.summary.data = null;
}
} catch (err) {
console.error("Gagal fetch dashboard summary:", err);
kependudukanDashboard.summary.data = null;
} finally {
kependudukanDashboard.summary.loading = false;
}
},
},
});
export default kependudukanDashboard;

View File

@@ -0,0 +1,205 @@
import ApiFetch from "@/lib/api-fetch";
import { proxy } from "valtio";
import { toast } from "react-toastify";
import { z } from "zod";
const templateDataBanjar = z.object({
nama: z.string().min(1, "Nama banjar harus diisi"),
penduduk: z.number().min(0, "Jumlah penduduk harus diisi"),
kk: z.number().min(0, "Jumlah KK harus diisi"),
miskin: z.number().min(0, "Jumlah penduduk miskin harus diisi"),
tahun: z.number().min(2000, "Tahun harus diisi"),
});
const dataBanjar = proxy({
create: {
form: {
nama: "",
penduduk: 0,
kk: 0,
miskin: 0,
tahun: new Date().getFullYear(),
},
loading: false,
async create() {
const cek = templateDataBanjar.safeParse(dataBanjar.create.form);
if (!cek.success) {
const err = `[${cek.error.issues.map((v) => `${v.path.join(".")}`).join("\n")}] required`;
toast.error(err);
return null;
}
try {
dataBanjar.create.loading = true;
const res = await ApiFetch.api.kependudukan.databanjar["create"].post(dataBanjar.create.form);
if (res.status === 200) {
const id = res.data?.data?.id;
if (id) {
toast.success("Sukses menambahkan data banjar");
dataBanjar.create.form = { nama: "", penduduk: 0, kk: 0, miskin: 0, tahun: new Date().getFullYear() };
dataBanjar.findMany.load();
return id;
}
}
toast.error("Gagal menambahkan data");
return null;
} catch (error) {
console.log((error as Error).message);
return null;
} finally {
dataBanjar.create.loading = false;
}
},
},
findMany: {
data: null as any[] | null,
page: 1,
totalPages: 1,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "", tahun = new Date().getFullYear()) => {
dataBanjar.findMany.loading = true;
dataBanjar.findMany.page = page;
dataBanjar.findMany.search = search;
try {
const query: any = { page, limit, tahun };
if (search) query.search = search;
const res = await ApiFetch.api.kependudukan.databanjar["find-many"].get({ query });
if (res.status === 200 && res.data?.success) {
dataBanjar.findMany.data = res.data.data ?? [];
dataBanjar.findMany.totalPages = res.data.totalPages ?? 1;
} else {
dataBanjar.findMany.data = [];
dataBanjar.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch data banjar paginated:", err);
dataBanjar.findMany.data = [];
dataBanjar.findMany.totalPages = 1;
} finally {
dataBanjar.findMany.loading = false;
}
},
},
findUnique: {
data: null as any | null,
async load(id: string) {
try {
const res = await fetch(`/api/kependudukan/databanjar/${id}`);
if (res.ok) {
const data = await res.json();
dataBanjar.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch data banjar:", res.statusText);
dataBanjar.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching data banjar:", error);
dataBanjar.findUnique.data = null;
}
},
},
update: {
id: "",
form: {
nama: "",
penduduk: 0,
kk: 0,
miskin: 0,
tahun: new Date().getFullYear(),
},
loading: false,
async submit() {
const id = this.id;
if (!id) {
toast.warn("ID tidak valid");
return null;
}
const formData = {
nama: this.form.nama,
penduduk: this.form.penduduk,
kk: this.form.kk,
miskin: this.form.miskin,
tahun: this.form.tahun,
};
const cek = templateDataBanjar.safeParse(formData);
if (!cek.success) {
const err = `[${cek.error.issues.map((v) => `${v.path.join(".")}`).join("\n")}] required`;
toast.error(err);
return null;
}
try {
this.loading = true;
const res = await fetch(`/api/kependudukan/databanjar/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
});
const result = await res.json();
if (!res.ok || !result?.success) {
throw new Error(result?.message || "Gagal update data");
}
toast.success("Berhasil update data!");
await dataBanjar.findMany.load();
return result.data;
} catch (error) {
console.error("Update error:", error);
toast.error("Gagal update data banjar");
throw error;
} finally {
this.loading = false;
}
},
},
delete: {
loading: false,
async byId(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
dataBanjar.delete.loading = true;
const response = await fetch(
`/api/kependudukan/databanjar/del/${id}`,
{
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
}
);
const result = await response.json();
if (response.ok && result?.success) {
toast.success(result.message || "Data banjar berhasil dihapus");
await dataBanjar.findMany.load();
} else {
toast.error(result?.message || "Gagal menghapus data banjar");
}
} catch (error) {
console.error("Gagal delete:", error);
toast.error("Terjadi kesalahan saat menghapus data banjar");
} finally {
dataBanjar.delete.loading = false;
}
},
},
});
export default dataBanjar;

View File

@@ -0,0 +1,197 @@
import ApiFetch from "@/lib/api-fetch";
import { proxy } from "valtio";
import { toast } from "react-toastify";
import { z } from "zod";
const templateDistribusiAgama = z.object({
agama: z.string().min(1, "Agama harus diisi"),
jumlah: z.number().min(0, "Jumlah harus diisi"),
tahun: z.number().min(2000, "Tahun harus diisi"),
});
const distribusiAgama = proxy({
create: {
form: {
agama: "",
jumlah: 0,
tahun: new Date().getFullYear(),
},
loading: false,
async create() {
const cek = templateDistribusiAgama.safeParse(distribusiAgama.create.form);
if (!cek.success) {
const err = `[${cek.error.issues.map((v) => `${v.path.join(".")}`).join("\n")}] required`;
toast.error(err);
return null;
}
try {
distribusiAgama.create.loading = true;
const res = await ApiFetch.api.kependudukan.distribusiagama["create"].post(distribusiAgama.create.form);
if (res.status === 200) {
const id = res.data?.data?.id;
if (id) {
toast.success("Sukses menambahkan distribusi agama");
distribusiAgama.create.form = { agama: "", jumlah: 0, tahun: new Date().getFullYear() };
distribusiAgama.findMany.load();
return id;
}
}
toast.error("Gagal menambahkan data");
return null;
} catch (error) {
console.log((error as Error).message);
return null;
} finally {
distribusiAgama.create.loading = false;
}
},
},
findMany: {
data: null as any[] | null,
page: 1,
totalPages: 1,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "", tahun = new Date().getFullYear()) => {
distribusiAgama.findMany.loading = true;
distribusiAgama.findMany.page = page;
distribusiAgama.findMany.search = search;
try {
const query: any = { page, limit, tahun };
if (search) query.search = search;
const res = await ApiFetch.api.kependudukan.distribusiagama["find-many"].get({ query });
if (res.status === 200 && res.data?.success) {
distribusiAgama.findMany.data = res.data.data ?? [];
distribusiAgama.findMany.totalPages = res.data.totalPages ?? 1;
} else {
distribusiAgama.findMany.data = [];
distribusiAgama.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch distribusi agama paginated:", err);
distribusiAgama.findMany.data = [];
distribusiAgama.findMany.totalPages = 1;
} finally {
distribusiAgama.findMany.loading = false;
}
},
},
findUnique: {
data: null as any | null,
async load(id: string) {
try {
const res = await fetch(`/api/kependudukan/distribusiagama/${id}`);
if (res.ok) {
const data = await res.json();
distribusiAgama.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch distribusiAgama:", res.statusText);
distribusiAgama.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching distribusiAgama:", error);
distribusiAgama.findUnique.data = null;
}
},
},
update: {
id: "",
form: {
agama: "",
jumlah: 0,
tahun: new Date().getFullYear(),
},
loading: false,
async submit() {
const id = this.id;
if (!id) {
toast.warn("ID tidak valid");
return null;
}
const formData = {
agama: this.form.agama,
jumlah: this.form.jumlah,
tahun: this.form.tahun,
};
const cek = templateDistribusiAgama.safeParse(formData);
if (!cek.success) {
const err = `[${cek.error.issues.map((v) => `${v.path.join(".")}`).join("\n")}] required`;
toast.error(err);
return null;
}
try {
this.loading = true;
const res = await fetch(`/api/kependudukan/distribusiagama/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
});
const result = await res.json();
if (!res.ok || !result?.success) {
throw new Error(result?.message || "Gagal update data");
}
toast.success("Berhasil update data!");
await distribusiAgama.findMany.load();
return result.data;
} catch (error) {
console.error("Update error:", error);
toast.error("Gagal update data distribusi agama");
throw error;
} finally {
this.loading = false;
}
},
},
delete: {
loading: false,
async byId(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
distribusiAgama.delete.loading = true;
const response = await fetch(
`/api/kependudukan/distribusiagama/del/${id}`,
{
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
}
);
const result = await response.json();
if (response.ok && result?.success) {
toast.success(result.message || "Distribusi agama berhasil dihapus");
await distribusiAgama.findMany.load();
} else {
toast.error(result?.message || "Gagal menghapus distribusi agama");
}
} catch (error) {
console.error("Gagal delete:", error);
toast.error("Terjadi kesalahan saat menghapus distribusi agama");
} finally {
distribusiAgama.delete.loading = false;
}
},
},
});
export default distribusiAgama;

View File

@@ -0,0 +1,197 @@
import ApiFetch from "@/lib/api-fetch";
import { proxy } from "valtio";
import { toast } from "react-toastify";
import { z } from "zod";
const templateDistribusiUmur = z.object({
rentangUmur: z.string().min(1, "Rentang umur harus diisi"),
jumlah: z.number().min(0, "Jumlah harus diisi"),
tahun: z.number().min(2000, "Tahun harus diisi"),
});
const distribusiUmur = proxy({
create: {
form: {
rentangUmur: "",
jumlah: 0,
tahun: new Date().getFullYear(),
},
loading: false,
async create() {
const cek = templateDistribusiUmur.safeParse(distribusiUmur.create.form);
if (!cek.success) {
const err = `[${cek.error.issues.map((v) => `${v.path.join(".")}`).join("\n")}] required`;
toast.error(err);
return null;
}
try {
distribusiUmur.create.loading = true;
const res = await ApiFetch.api.kependudukan.distribusiumur["create"].post(distribusiUmur.create.form);
if (res.status === 200) {
const id = res.data?.data?.id;
if (id) {
toast.success("Sukses menambahkan distribusi umur");
distribusiUmur.create.form = { rentangUmur: "", jumlah: 0, tahun: new Date().getFullYear() };
distribusiUmur.findMany.load();
return id;
}
}
toast.error("Gagal menambahkan data");
return null;
} catch (error) {
console.log((error as Error).message);
return null;
} finally {
distribusiUmur.create.loading = false;
}
},
},
findMany: {
data: null as any[] | null,
page: 1,
totalPages: 1,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "", tahun = new Date().getFullYear()) => {
distribusiUmur.findMany.loading = true;
distribusiUmur.findMany.page = page;
distribusiUmur.findMany.search = search;
try {
const query: any = { page, limit, tahun };
if (search) query.search = search;
const res = await ApiFetch.api.kependudukan.distribusiumur["find-many"].get({ query });
if (res.status === 200 && res.data?.success) {
distribusiUmur.findMany.data = res.data.data ?? [];
distribusiUmur.findMany.totalPages = res.data.totalPages ?? 1;
} else {
distribusiUmur.findMany.data = [];
distribusiUmur.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch distribusi umur paginated:", err);
distribusiUmur.findMany.data = [];
distribusiUmur.findMany.totalPages = 1;
} finally {
distribusiUmur.findMany.loading = false;
}
},
},
findUnique: {
data: null as any | null,
async load(id: string) {
try {
const res = await fetch(`/api/kependudukan/distribusiumur/${id}`);
if (res.ok) {
const data = await res.json();
distribusiUmur.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch distribusi umur:", res.statusText);
distribusiUmur.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching distribusi umur:", error);
distribusiUmur.findUnique.data = null;
}
},
},
update: {
id: "",
form: {
rentangUmur: "",
jumlah: 0,
tahun: new Date().getFullYear(),
},
loading: false,
async submit() {
const id = this.id;
if (!id) {
toast.warn("ID tidak valid");
return null;
}
const formData = {
rentangUmur: this.form.rentangUmur,
jumlah: this.form.jumlah,
tahun: this.form.tahun,
};
const cek = templateDistribusiUmur.safeParse(formData);
if (!cek.success) {
const err = `[${cek.error.issues.map((v) => `${v.path.join(".")}`).join("\n")}] required`;
toast.error(err);
return null;
}
try {
this.loading = true;
const res = await fetch(`/api/kependudukan/distribusiumur/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
});
const result = await res.json();
if (!res.ok || !result?.success) {
throw new Error(result?.message || "Gagal update data");
}
toast.success("Berhasil update data!");
await distribusiUmur.findMany.load();
return result.data;
} catch (error) {
console.error("Update error:", error);
toast.error("Gagal update data distribusi umur");
throw error;
} finally {
this.loading = false;
}
},
},
delete: {
loading: false,
async byId(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
distribusiUmur.delete.loading = true;
const response = await fetch(
`/api/kependudukan/distribusiumur/del/${id}`,
{
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
}
);
const result = await response.json();
if (response.ok && result?.success) {
toast.success(result.message || "Distribusi umur berhasil dihapus");
await distribusiUmur.findMany.load();
} else {
toast.error(result?.message || "Gagal menghapus distribusi umur");
}
} catch (error) {
console.error("Gagal delete:", error);
toast.error("Terjadi kesalahan saat menghapus distribusi umur");
} finally {
distribusiUmur.delete.loading = false;
}
},
},
});
export default distribusiUmur;

View File

@@ -0,0 +1,209 @@
import ApiFetch from "@/lib/api-fetch";
import { proxy } from "valtio";
import { toast } from "react-toastify";
import { z } from "zod";
const templateMigrasiPenduduk = z.object({
jenis: z.string().min(1, "Jenis migrasi harus diisi"),
nama: z.string().min(1, "Nama harus diisi"),
tanggal: z.string().min(1, "Tanggal harus diisi"),
asalTujuan: z.string().min(1, "Asal/Tujuan harus diisi"),
alasan: z.string().optional(),
jenisKelamin: z.string().optional(),
});
const migrasiPenduduk = proxy({
create: {
form: {
jenis: "",
nama: "",
tanggal: "",
asalTujuan: "",
alasan: "",
jenisKelamin: "",
},
loading: false,
async create() {
const cek = templateMigrasiPenduduk.safeParse(migrasiPenduduk.create.form);
if (!cek.success) {
const err = `[${cek.error.issues.map((v) => `${v.path.join(".")}`).join("\n")}] required`;
toast.error(err);
return null;
}
try {
migrasiPenduduk.create.loading = true;
const res = await ApiFetch.api.kependudukan.migrasipenduduk["create"].post(migrasiPenduduk.create.form);
if (res.status === 200) {
const id = res.data?.data?.id;
if (id) {
toast.success("Sukses menambahkan data migrasi penduduk");
migrasiPenduduk.create.form = { jenis: "", nama: "", tanggal: "", asalTujuan: "", alasan: "", jenisKelamin: "" };
migrasiPenduduk.findMany.load();
return id;
}
}
toast.error("Gagal menambahkan data");
return null;
} catch (error) {
console.log((error as Error).message);
return null;
} finally {
migrasiPenduduk.create.loading = false;
}
},
},
findMany: {
data: null as any[] | null,
page: 1,
totalPages: 1,
loading: false,
search: "",
load: async (page = 1, limit = 10, search = "", tahun = new Date().getFullYear()) => {
migrasiPenduduk.findMany.loading = true;
migrasiPenduduk.findMany.page = page;
migrasiPenduduk.findMany.search = search;
try {
const query: any = { page, limit, tahun };
if (search) query.search = search;
const res = await ApiFetch.api.kependudukan.migrasipenduduk["find-many"].get({ query });
if (res.status === 200 && res.data?.success) {
migrasiPenduduk.findMany.data = res.data.data ?? [];
migrasiPenduduk.findMany.totalPages = res.data.totalPages ?? 1;
} else {
migrasiPenduduk.findMany.data = [];
migrasiPenduduk.findMany.totalPages = 1;
}
} catch (err) {
console.error("Gagal fetch migrasi penduduk paginated:", err);
migrasiPenduduk.findMany.data = [];
migrasiPenduduk.findMany.totalPages = 1;
} finally {
migrasiPenduduk.findMany.loading = false;
}
},
},
findUnique: {
data: null as any | null,
async load(id: string) {
try {
const res = await fetch(`/api/kependudukan/migrasipenduduk/${id}`);
if (res.ok) {
const data = await res.json();
migrasiPenduduk.findUnique.data = data.data ?? null;
} else {
console.error("Failed to fetch migrasi penduduk:", res.statusText);
migrasiPenduduk.findUnique.data = null;
}
} catch (error) {
console.error("Error fetching migrasi penduduk:", error);
migrasiPenduduk.findUnique.data = null;
}
},
},
update: {
id: "",
form: {
jenis: "",
nama: "",
tanggal: "",
asalTujuan: "",
alasan: "",
jenisKelamin: "",
},
loading: false,
async submit() {
const id = this.id;
if (!id) {
toast.warn("ID tidak valid");
return null;
}
const formData = {
jenis: this.form.jenis,
nama: this.form.nama,
tanggal: this.form.tanggal,
asalTujuan: this.form.asalTujuan,
alasan: this.form.alasan,
jenisKelamin: this.form.jenisKelamin,
};
const cek = templateMigrasiPenduduk.safeParse(formData);
if (!cek.success) {
const err = `[${cek.error.issues.map((v) => `${v.path.join(".")}`).join("\n")}] required`;
toast.error(err);
return null;
}
try {
this.loading = true;
const res = await fetch(`/api/kependudukan/migrasipenduduk/${id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(formData),
});
const result = await res.json();
if (!res.ok || !result?.success) {
throw new Error(result?.message || "Gagal update data");
}
toast.success("Berhasil update data!");
await migrasiPenduduk.findMany.load();
return result.data;
} catch (error) {
console.error("Update error:", error);
toast.error("Gagal update data migrasi penduduk");
throw error;
} finally {
this.loading = false;
}
},
},
delete: {
loading: false,
async byId(id: string) {
if (!id) return toast.warn("ID tidak valid");
try {
migrasiPenduduk.delete.loading = true;
const response = await fetch(
`/api/kependudukan/migrasipenduduk/del/${id}`,
{
method: "DELETE",
headers: {
"Content-Type": "application/json",
},
}
);
const result = await response.json();
if (response.ok && result?.success) {
toast.success(result.message || "Data migrasi penduduk berhasil dihapus");
await migrasiPenduduk.findMany.load();
} else {
toast.error(result?.message || "Gagal menghapus data migrasi penduduk");
}
} catch (error) {
console.error("Gagal delete:", error);
toast.error("Terjadi kesalahan saat menghapus data migrasi penduduk");
} finally {
migrasiPenduduk.delete.loading = false;
}
},
},
});
export default migrasiPenduduk;

View File

@@ -0,0 +1,249 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
Title,
NumberInput,
TextInput
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
import dataBanjar from '../../../_state/kependudukan/data-banjar';
interface FormData {
nama: string;
penduduk: number;
kk: number;
miskin: number;
tahun: number;
}
export default function EditDataBanjar() {
const router = useRouter();
const { id } = useParams() as { id: string };
const stateDataBanjar = useProxy(dataBanjar);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<FormData>({
nama: '',
penduduk: 0,
kk: 0,
miskin: 0,
tahun: new Date().getFullYear(),
});
const [originalData, setOriginalData] = useState<FormData>({
nama: '',
penduduk: 0,
kk: 0,
miskin: 0,
tahun: new Date().getFullYear(),
});
const currentYear = new Date().getFullYear();
const isFormValid = () => {
return (
formData.nama?.trim() !== '' &&
formData.penduduk !== null &&
formData.penduduk >= 0 &&
formData.kk !== null &&
formData.kk >= 0 &&
formData.miskin !== null &&
formData.miskin >= 0 &&
formData.tahun !== null
);
};
useEffect(() => {
if (!id) return;
const loadData = async () => {
try {
setIsSubmitting(true);
stateDataBanjar.update.id = id;
await stateDataBanjar.findUnique.load(id);
const data = stateDataBanjar.findUnique.data;
if (data) {
setFormData({
nama: data.nama ?? '',
penduduk: Number(data.penduduk ?? 0),
kk: Number(data.kk ?? 0),
miskin: Number(data.miskin ?? 0),
tahun: Number(data.tahun ?? currentYear),
});
setOriginalData({
nama: data.nama ?? '',
penduduk: Number(data.penduduk ?? 0),
kk: Number(data.kk ?? 0),
miskin: Number(data.miskin ?? 0),
tahun: Number(data.tahun ?? currentYear),
});
}
} catch (error) {
console.error('Error loading data:', error);
toast.error('Gagal memuat data');
} finally {
setIsSubmitting(false);
}
};
loadData();
}, [id]);
const handleChange = useCallback(
(field: keyof FormData) =>
(value: any) => {
const val =
field === 'penduduk' || field === 'kk' || field === 'miskin' || field === 'tahun'
? Number(value || 0)
: value;
setFormData((prev) => ({ ...prev, [field]: val }));
},
[]
);
const handleResetForm = () => {
setFormData({
nama: originalData.nama,
penduduk: Number(originalData.penduduk),
kk: Number(originalData.kk),
miskin: Number(originalData.miskin),
tahun: Number(originalData.tahun),
});
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
stateDataBanjar.update.id = id;
stateDataBanjar.update.form = { ...formData };
await stateDataBanjar.update.submit();
toast.success('Data berhasil diperbarui');
router.push('/admin/kependudukan/data-banjar');
} catch (error) {
console.error('Error updating data:', error);
toast.error('Gagal memperbarui data');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'xs' }} py="xs">
{/* Header */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Data Banjar
</Title>
</Group>
{/* Form Card */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
label="Nama Banjar"
placeholder="Masukkan nama banjar"
value={formData.nama}
onChange={handleChange('nama')}
required
/>
<NumberInput
label="Jumlah Penduduk"
placeholder="Masukkan jumlah penduduk"
value={formData.penduduk}
onChange={handleChange('penduduk')}
min={0}
required
/>
<NumberInput
label="Jumlah KK"
placeholder="Masukkan jumlah KK"
value={formData.kk}
onChange={handleChange('kk')}
min={0}
required
/>
<NumberInput
label="Jumlah Penduduk Miskin"
placeholder="Masukkan jumlah penduduk miskin"
value={formData.miskin}
onChange={handleChange('miskin')}
min={0}
required
/>
<NumberInput
label="Tahun"
placeholder="Masukkan tahun"
value={formData.tahun}
onChange={handleChange('tahun')}
min={2000}
max={currentYear + 1}
required
/>
<Group justify="flex-end">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}

View File

@@ -0,0 +1,189 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
Title,
NumberInput,
TextInput
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import dataBanjar from '../../../_state/kependudukan/data-banjar';
import { toast } from 'react-toastify';
function CreateDataBanjar() {
const stateDataBanjar = useProxy(dataBanjar);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const currentYear = new Date().getFullYear();
const yearOptions = Array.from({ length: 10 }, (_, i) => ({
value: String(currentYear - i),
label: String(currentYear - i),
}));
const isFormValid = () => {
return (
stateDataBanjar.create.form.nama?.trim() !== '' &&
stateDataBanjar.create.form.penduduk !== null &&
stateDataBanjar.create.form.penduduk >= 0 &&
stateDataBanjar.create.form.kk !== null &&
stateDataBanjar.create.form.kk >= 0 &&
stateDataBanjar.create.form.miskin !== null &&
stateDataBanjar.create.form.miskin >= 0 &&
stateDataBanjar.create.form.tahun !== null
);
};
const resetForm = () => {
stateDataBanjar.create.form = {
nama: '',
penduduk: 0,
kk: 0,
miskin: 0,
tahun: currentYear,
};
};
const handleSubmit = async () => {
try {
const id = await stateDataBanjar.create.create();
if (id) {
resetForm();
router.push('/admin/kependudukan/data-banjar');
}
} catch (error) {
console.error('Error creating data banjar:', error);
toast.error('Terjadi kesalahan saat menambah data banjar');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'xs' }} py="xs">
{/* Header */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah Data Banjar
</Title>
</Group>
{/* Form */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<TextInput
label="Nama Banjar"
placeholder="Masukkan nama banjar"
value={stateDataBanjar.create.form.nama}
onChange={(e) => {
stateDataBanjar.create.form.nama = e.currentTarget.value;
}}
required
/>
<NumberInput
label="Jumlah Penduduk"
placeholder="Masukkan jumlah penduduk"
value={stateDataBanjar.create.form.penduduk}
onChange={(val) => {
stateDataBanjar.create.form.penduduk = Number(val || 0);
}}
min={0}
required
/>
<NumberInput
label="Jumlah KK"
placeholder="Masukkan jumlah KK"
value={stateDataBanjar.create.form.kk}
onChange={(val) => {
stateDataBanjar.create.form.kk = Number(val || 0);
}}
min={0}
required
/>
<NumberInput
label="Jumlah Penduduk Miskin"
placeholder="Masukkan jumlah penduduk miskin"
value={stateDataBanjar.create.form.miskin}
onChange={(val) => {
stateDataBanjar.create.form.miskin = Number(val || 0);
}}
min={0}
required
/>
<NumberInput
label="Tahun"
placeholder="Masukkan tahun"
value={stateDataBanjar.create.form.tahun}
onChange={(val) => {
stateDataBanjar.create.form.tahun = Number(val || currentYear);
}}
min={2000}
max={currentYear + 1}
required
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default CreateDataBanjar;

View File

@@ -0,0 +1,304 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'
import colors from '@/con/colors';
import {
Box,
Button,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title
} from '@mantine/core';
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../_com/header';
import { ModalKonfirmasiHapus } from '../../_com/modalKonfirmasiHapus';
import dataBanjar from '../../_state/kependudukan/data-banjar';
function DataBanjarAdmin() {
const [search, setSearch] = useState('');
return (
<Box>
<HeaderSearch
title='Data Banjar'
placeholder='Cari nama banjar...'
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.currentTarget.value)}
/>
<ListDataBanjar search={search} />
</Box>
);
}
function ListDataBanjar({ search }: { search: string }) {
type DataBanjarType = {
id: string;
nama: string;
penduduk: number;
kk: number;
miskin: number;
tahun: number;
};
const router = useRouter();
const stateDataBanjar = useProxy(dataBanjar);
const [modalHapus, setModalHapus] = useState(false);
const [debouncedSearch] = useDebouncedValue(search, 1000);
const [selectedId, setSelectedId] = useState<string | null>(null);
const {
data,
page,
totalPages,
loading,
load,
} = stateDataBanjar.findMany;
const handleDelete = () => {
if (selectedId) {
stateDataBanjar.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
}
};
useShallowEffect(() => {
load(page, 10, debouncedSearch);
}, [page, debouncedSearch]);
const filteredData = data || [];
if (loading || !data) {
return (
<Stack py={10}>
<Skeleton height={600} radius="md" />
</Stack>
);
}
return (
<Box py={{ base: 'sm', md: 'md' }}>
<Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md">
<Group justify="space-between" mb={{ base: 'sm', md: 'md' }}>
<Title order={4} lh={{ base: 1.2, md: 1.15 }}>
List Data Banjar
</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/kependudukan/data-banjar/create')}
fz={{ base: 'sm', md: 'md' }}
>
Tambah Baru
</Button>
</Group>
{/* Desktop Table */}
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
<Table
highlightOnHover
miw={0}
style={{
tableLayout: 'fixed',
width: '100%',
}}
>
<TableThead>
<TableTr>
<TableTh style={{ width: '25%' }}>Nama Banjar</TableTh>
<TableTh style={{ width: '15%' }}>Penduduk</TableTh>
<TableTh style={{ width: '15%' }}>KK</TableTh>
<TableTh style={{ width: '15%' }}>Miskin</TableTh>
<TableTh style={{ width: '10%' }}>Tahun</TableTh>
<TableTh style={{ width: '10%' }}>Edit</TableTh>
<TableTh style={{ width: '10%' }}>Hapus</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item: DataBanjarType) => (
<TableTr key={item.id}>
<TableTd>{item.nama}</TableTd>
<TableTd>{item.penduduk.toLocaleString('id-ID')}</TableTd>
<TableTd>{item.kk.toLocaleString('id-ID')}</TableTd>
<TableTd>{item.miskin.toLocaleString('id-ID')}</TableTd>
<TableTd>{item.tahun}</TableTd>
<TableTd>
<Button
variant="light"
color="green"
onClick={() =>
router.push(`/admin/kependudukan/data-banjar/${item.id}`)
}
fz="sm"
px="xs"
py="xs"
>
<IconEdit size={16} />
</Button>
</TableTd>
<TableTd>
<Button
variant="light"
color="red"
disabled={stateDataBanjar.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
fz="sm"
px="xs"
py="xs"
>
<IconTrash size={16} />
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={7}>
<Center py={20}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data banjar yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
{/* Mobile Card */}
<Box hiddenFrom="md">
<Stack gap="xs">
{filteredData.length > 0 ? (
filteredData.map((item: DataBanjarType) => (
<Paper key={item.id} withBorder p="sm" radius="sm">
<Stack gap={"xs"}>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Nama Banjar
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.nama}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Jumlah Penduduk
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.penduduk.toLocaleString('id-ID')}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
KK
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.kk.toLocaleString('id-ID')}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Penduduk Miskin
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.miskin.toLocaleString('id-ID')}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Tahun
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.tahun}
</Text>
</Box>
<Group justify="flex-end" gap="xs">
<Button
variant="light"
color="green"
onClick={() =>
router.push(`/admin/kependudukan/data-banjar/${item.id}`)
}
fz="xs"
px="xs"
py="xs"
>
<IconEdit size={14} />
</Button>
<Button
variant="light"
color="red"
disabled={stateDataBanjar.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
fz="xs"
px="xs"
py="xs"
>
<IconTrash size={14} />
</Button>
</Group>
</Stack>
</Paper>
))
) : (
<Center py={20}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data banjar yang cocok
</Text>
</Center>
)}
</Stack>
</Box>
</Paper>
{/* Pagination */}
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10, search);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
mt="md"
mb="md"
color="blue"
radius="md"
/>
</Center>
{/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleDelete}
text="Apakah anda yakin ingin menghapus data banjar ini?"
/>
</Box>
);
}
export default DataBanjarAdmin;

View File

@@ -0,0 +1,232 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
Title,
NumberInput,
Select
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
import distribusiAgama from '../../../_state/kependudukan/distribusi-agama';
interface FormData {
agama: string;
jumlah: number;
tahun: number;
}
export default function EditDistribusiAgama() {
const router = useRouter();
const { id } = useParams() as { id: string };
const stateDistribusiAgama = useProxy(distribusiAgama);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<FormData>({
agama: '',
jumlah: 0,
tahun: new Date().getFullYear(),
});
const [originalData, setOriginalData] = useState<FormData>({
agama: '',
jumlah: 0,
tahun: new Date().getFullYear(),
});
const currentYear = new Date().getFullYear();
const yearOptions = Array.from({ length: 10 }, (_, i) => ({
value: String(currentYear - i),
label: String(currentYear - i),
}));
const agamaOptions = [
{ value: 'HINDU', label: 'Hindu' },
{ value: 'ISLAM', label: 'Islam' },
{ value: 'KRISTEN', label: 'Kristen' },
{ value: 'KRISTEN_PROTESTAN', label: 'Kristen Protestan' },
{ value: 'KRISTEN_KATOLIK', label: 'Kristen Katolik' },
{ value: 'BUDDHA', label: 'Buddha' },
{ value: 'KONGHUCU', label: 'Konghucu' },
{ value: 'LAINNYA', label: 'Lainnya' },
];
const isFormValid = () => {
return (
formData.agama?.trim() !== '' &&
formData.jumlah !== null &&
formData.jumlah >= 0 &&
formData.tahun !== null
);
};
useEffect(() => {
if (!id) return;
const loadData = async () => {
try {
setIsSubmitting(true);
stateDistribusiAgama.update.id = id;
await stateDistribusiAgama.findUnique.load(id);
const data = stateDistribusiAgama.findUnique.data;
if (data) {
setFormData({
agama: data.agama ?? '',
jumlah: Number(data.jumlah ?? 0),
tahun: Number(data.tahun ?? currentYear),
});
setOriginalData({
agama: data.agama ?? '',
jumlah: Number(data.jumlah ?? 0),
tahun: Number(data.tahun ?? currentYear),
});
}
} catch (error) {
console.error('Error loading data:', error);
toast.error('Gagal memuat data');
} finally {
setIsSubmitting(false);
}
};
loadData();
}, [id]);
const handleChange = useCallback(
(field: keyof FormData) =>
(value: any) => {
const val =
field === 'jumlah' || field === 'tahun'
? Number(value || 0)
: value;
setFormData((prev) => ({ ...prev, [field]: val }));
},
[]
);
const handleResetForm = () => {
setFormData({
agama: originalData.agama,
jumlah: Number(originalData.jumlah),
tahun: Number(originalData.tahun),
});
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
stateDistribusiAgama.update.id = id;
stateDistribusiAgama.update.form = { ...formData };
await stateDistribusiAgama.update.submit();
toast.success('Data berhasil diperbarui');
router.push('/admin/kependudukan/distribusi-agama');
} catch (error) {
console.error('Error updating data:', error);
toast.error('Gagal memperbarui data');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'xs' }} py="xs">
{/* Header */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Distribusi Agama
</Title>
</Group>
{/* Form Card */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<Select
label="Agama"
placeholder="Pilih agama"
data={agamaOptions}
value={formData.agama}
onChange={handleChange('agama')}
required
searchable
/>
<NumberInput
label="Jumlah"
placeholder="Masukkan jumlah"
value={formData.jumlah}
onChange={handleChange('jumlah')}
min={0}
required
/>
<Select
label="Tahun"
placeholder="Pilih tahun"
data={yearOptions}
value={String(formData.tahun)}
onChange={handleChange('tahun')}
required
/>
<Group justify="flex-end">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}

View File

@@ -0,0 +1,174 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
TextInput,
Title,
NumberInput,
Select
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import distribusiAgama from '../../../_state/kependudukan/distribusi-agama';
import { toast } from 'react-toastify';
function CreateDistribusiAgama() {
const stateDistribusiAgama = useProxy(distribusiAgama);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const agamaOptions = [
{ value: 'HINDU', label: 'Hindu' },
{ value: 'ISLAM', label: 'Islam' },
{ value: 'KRISTEN', label: 'Kristen' },
{ value: 'KRISTEN_PROTESTAN', label: 'Kristen Protestan' },
{ value: 'KRISTEN_KATOLIK', label: 'Kristen Katolik' },
{ value: 'BUDDHA', label: 'Buddha' },
{ value: 'KONGHUCU', label: 'Konghucu' },
{ value: 'LAINNYA', label: 'Lainnya' },
];
const currentYear = new Date().getFullYear();
const yearOptions = Array.from({ length: 10 }, (_, i) => ({
value: String(currentYear - i),
label: String(currentYear - i),
}));
const isFormValid = () => {
return (
stateDistribusiAgama.create.form.agama?.trim() !== '' &&
stateDistribusiAgama.create.form.jumlah !== null &&
stateDistribusiAgama.create.form.jumlah >= 0 &&
stateDistribusiAgama.create.form.tahun !== null
);
};
const resetForm = () => {
stateDistribusiAgama.create.form = {
agama: '',
jumlah: 0,
tahun: currentYear,
};
};
const handleSubmit = async () => {
try {
const id = await stateDistribusiAgama.create.create();
if (id) {
resetForm();
router.push('/admin/kependudukan/distribusi-agama');
}
} catch (error) {
console.error('Error creating distribusi agama:', error);
toast.error('Terjadi kesalahan saat menambah distribusi agama');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'xs' }} py="xs">
{/* Header */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah Distribusi Agama
</Title>
</Group>
{/* Form */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<Select
label="Agama"
placeholder="Pilih agama"
data={agamaOptions}
value={stateDistribusiAgama.create.form.agama}
onChange={(val) => {
stateDistribusiAgama.create.form.agama = val || '';
}}
required
searchable
/>
<NumberInput
label="Jumlah"
placeholder="Masukkan jumlah"
value={stateDistribusiAgama.create.form.jumlah}
onChange={(val) => {
stateDistribusiAgama.create.form.jumlah = Number(val || 0);
}}
min={0}
required
/>
<Select
label="Tahun"
placeholder="Pilih tahun"
data={yearOptions}
value={String(stateDistribusiAgama.create.form.tahun)}
onChange={(val) => {
stateDistribusiAgama.create.form.tahun = Number(val || currentYear);
}}
required
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default CreateDistribusiAgama;

View File

@@ -0,0 +1,283 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'
import colors from '@/con/colors';
import {
Box,
Button,
Center,
Flex,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title
} from '@mantine/core';
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../_com/header';
import { ModalKonfirmasiHapus } from '../../_com/modalKonfirmasiHapus';
import distribusiAgama from '../../_state/kependudukan/distribusi-agama';
function DistribusiAgamaAdmin() {
const [search, setSearch] = useState('');
return (
<Box>
<HeaderSearch
title='Distribusi Agama'
placeholder='Cari agama...'
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.currentTarget.value)}
/>
<ListDistribusiAgama search={search} />
</Box>
);
}
function ListDistribusiAgama({ search }: { search: string }) {
type DistribusiAgamaType = {
id: string;
agama: string;
jumlah: number;
tahun: number;
};
const router = useRouter();
const stateDistribusiAgama = useProxy(distribusiAgama);
const [modalHapus, setModalHapus] = useState(false);
const [debouncedSearch] = useDebouncedValue(search, 1000);
const [selectedId, setSelectedId] = useState<string | null>(null);
const {
data,
page,
totalPages,
loading,
load,
} = stateDistribusiAgama.findMany;
const handleDelete = () => {
if (selectedId) {
stateDistribusiAgama.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
}
};
useShallowEffect(() => {
load(page, 10, debouncedSearch);
}, [page, debouncedSearch]);
const filteredData = data || [];
if (loading || !data) {
return (
<Stack py={10}>
<Skeleton height={600} radius="md" />
</Stack>
);
}
return (
<Box py={{ base: 'sm', md: 'md' }}>
<Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md">
<Group justify="space-between" mb={{ base: 'sm', md: 'md' }}>
<Title order={4} lh={{ base: 1.2, md: 1.15 }}>
List Distribusi Agama
</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/kependudukan/distribusi-agama/create')}
fz={{ base: 'sm', md: 'md' }}
>
Tambah Baru
</Button>
</Group>
{/* Desktop Table */}
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
<Table
highlightOnHover
miw={0}
style={{
tableLayout: 'fixed',
width: '100%',
}}
>
<TableThead>
<TableTr>
<TableTh style={{ width: '40%' }}>Agama</TableTh>
<TableTh style={{ width: '20%' }}>Jumlah</TableTh>
<TableTh style={{ width: '20%' }}>Tahun</TableTh>
<TableTh style={{ width: '10%' }}>Edit</TableTh>
<TableTh style={{ width: '10%' }}>Hapus</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item: DistribusiAgamaType) => (
<TableTr key={item.id}>
<TableTd>{item.agama}</TableTd>
<TableTd>{item.jumlah.toLocaleString('id-ID')}</TableTd>
<TableTd>{item.tahun}</TableTd>
<TableTd>
<Button
variant="light"
color="green"
onClick={() =>
router.push(`/admin/kependudukan/distribusi-agama/${item.id}`)
}
fz="sm"
px="xs"
py="xs"
>
<IconEdit size={16} />
</Button>
</TableTd>
<TableTd>
<Button
variant="light"
color="red"
disabled={stateDistribusiAgama.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
fz="sm"
px="xs"
py="xs"
>
<IconTrash size={16} />
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={5}>
<Center py={20}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data distribusi agama yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
{/* Mobile Card */}
<Box hiddenFrom="md">
<Stack gap="xs">
{filteredData.length > 0 ? (
filteredData.map((item: DistribusiAgamaType) => (
<Paper key={item.id} withBorder p="sm" radius="sm">
<Stack gap={"xs"}>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Agama
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.agama}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Jumlah
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.jumlah.toLocaleString('id-ID')}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Tahun
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.tahun}
</Text>
</Box>
<Group justify="flex-end" gap="xs">
<Button
variant="light"
color="green"
onClick={() =>
router.push(`/admin/kependudukan/distribusi-agama/${item.id}`)
}
fz="xs"
px="xs"
py="xs"
>
<IconEdit size={14} />
</Button>
<Button
variant="light"
color="red"
disabled={stateDistribusiAgama.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
fz="xs"
px="xs"
py="xs"
>
<IconTrash size={14} />
</Button>
</Group>
</Stack>
</Paper>
))
) : (
<Center py={20}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data distribusi agama yang cocok
</Text>
</Center>
)}
</Stack>
</Box>
</Paper>
{/* Pagination */}
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10, search);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
mt="md"
mb="md"
color="blue"
radius="md"
/>
</Center>
{/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleDelete}
text="Apakah anda yakin ingin menghapus data distribusi agama ini?"
/>
</Box>
);
}
export default DistribusiAgamaAdmin;

View File

@@ -0,0 +1,232 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
Title,
NumberInput,
Select
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
import distribusiUmur from '../../../_state/kependudukan/distribusi-umur';
interface FormData {
rentangUmur: string;
jumlah: number;
tahun: number;
}
export default function EditDistribusiUmur() {
const router = useRouter();
const { id } = useParams() as { id: string };
const stateDistribusiUmur = useProxy(distribusiUmur);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<FormData>({
rentangUmur: '',
jumlah: 0,
tahun: new Date().getFullYear(),
});
const [originalData, setOriginalData] = useState<FormData>({
rentangUmur: '',
jumlah: 0,
tahun: new Date().getFullYear(),
});
const currentYear = new Date().getFullYear();
const yearOptions = Array.from({ length: 10 }, (_, i) => ({
value: String(currentYear - i),
label: String(currentYear - i),
}));
const rentangUmurOptions = [
{ value: '0-5', label: '0-5 Tahun' },
{ value: '6-12', label: '6-12 Tahun' },
{ value: '13-17', label: '13-17 Tahun' },
{ value: '18-25', label: '18-25 Tahun' },
{ value: '26-35', label: '26-35 Tahun' },
{ value: '36-45', label: '36-45 Tahun' },
{ value: '46-55', label: '46-55 Tahun' },
{ value: '56-65', label: '56-65 Tahun' },
{ value: '65+', label: '65+ Tahun' },
];
const isFormValid = () => {
return (
formData.rentangUmur?.trim() !== '' &&
formData.jumlah !== null &&
formData.jumlah >= 0 &&
formData.tahun !== null
);
};
useEffect(() => {
if (!id) return;
const loadData = async () => {
try {
setIsSubmitting(true);
stateDistribusiUmur.update.id = id;
await stateDistribusiUmur.findUnique.load(id);
const data = stateDistribusiUmur.findUnique.data;
if (data) {
setFormData({
rentangUmur: data.rentangUmur ?? '',
jumlah: Number(data.jumlah ?? 0),
tahun: Number(data.tahun ?? currentYear),
});
setOriginalData({
rentangUmur: data.rentangUmur ?? '',
jumlah: Number(data.jumlah ?? 0),
tahun: Number(data.tahun ?? currentYear),
});
}
} catch (error) {
console.error('Error loading data:', error);
toast.error('Gagal memuat data');
} finally {
setIsSubmitting(false);
}
};
loadData();
}, [id]);
const handleChange = useCallback(
(field: keyof FormData) =>
(value: any) => {
const val =
field === 'jumlah' || field === 'tahun'
? Number(value || 0)
: value;
setFormData((prev) => ({ ...prev, [field]: val }));
},
[]
);
const handleResetForm = () => {
setFormData({
rentangUmur: originalData.rentangUmur,
jumlah: Number(originalData.jumlah),
tahun: Number(originalData.tahun),
});
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
stateDistribusiUmur.update.id = id;
stateDistribusiUmur.update.form = { ...formData };
await stateDistribusiUmur.update.submit();
toast.success('Data berhasil diperbarui');
router.push('/admin/kependudukan/distribusi-umur');
} catch (error) {
console.error('Error updating data:', error);
toast.error('Gagal memperbarui data');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'xs' }} py="xs">
{/* Header */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Distribusi Umur
</Title>
</Group>
{/* Form Card */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<Select
label="Rentang Umur"
placeholder="Pilih rentang umur"
data={rentangUmurOptions}
value={formData.rentangUmur}
onChange={handleChange('rentangUmur')}
required
searchable
/>
<NumberInput
label="Jumlah"
placeholder="Masukkan jumlah"
value={formData.jumlah}
onChange={handleChange('jumlah')}
min={0}
required
/>
<Select
label="Tahun"
placeholder="Pilih tahun"
data={yearOptions}
value={String(formData.tahun)}
onChange={handleChange('tahun')}
required
/>
<Group justify="flex-end">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}

View File

@@ -0,0 +1,174 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
Title,
NumberInput,
Select
} from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import distribusiUmur from '../../../_state/kependudukan/distribusi-umur';
import { toast } from 'react-toastify';
function CreateDistribusiUmur() {
const stateDistribusiUmur = useProxy(distribusiUmur);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const rentangUmurOptions = [
{ value: '0-5', label: '0-5 Tahun' },
{ value: '6-12', label: '6-12 Tahun' },
{ value: '13-17', label: '13-17 Tahun' },
{ value: '18-25', label: '18-25 Tahun' },
{ value: '26-35', label: '26-35 Tahun' },
{ value: '36-45', label: '36-45 Tahun' },
{ value: '46-55', label: '46-55 Tahun' },
{ value: '56-65', label: '56-65 Tahun' },
{ value: '65+', label: '65+ Tahun' },
];
const currentYear = new Date().getFullYear();
const yearOptions = Array.from({ length: 10 }, (_, i) => ({
value: String(currentYear - i),
label: String(currentYear - i),
}));
const isFormValid = () => {
return (
stateDistribusiUmur.create.form.rentangUmur?.trim() !== '' &&
stateDistribusiUmur.create.form.jumlah !== null &&
stateDistribusiUmur.create.form.jumlah >= 0 &&
stateDistribusiUmur.create.form.tahun !== null
);
};
const resetForm = () => {
stateDistribusiUmur.create.form = {
rentangUmur: '',
jumlah: 0,
tahun: currentYear,
};
};
const handleSubmit = async () => {
try {
const id = await stateDistribusiUmur.create.create();
if (id) {
resetForm();
router.push('/admin/kependudukan/distribusi-umur');
}
} catch (error) {
console.error('Error creating distribusi umur:', error);
toast.error('Terjadi kesalahan saat menambah distribusi umur');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'xs' }} py="xs">
{/* Header */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah Distribusi Umur
</Title>
</Group>
{/* Form */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<Select
label="Rentang Umur"
placeholder="Pilih rentang umur"
data={rentangUmurOptions}
value={stateDistribusiUmur.create.form.rentangUmur}
onChange={(val) => {
stateDistribusiUmur.create.form.rentangUmur = val || '';
}}
required
searchable
/>
<NumberInput
label="Jumlah"
placeholder="Masukkan jumlah"
value={stateDistribusiUmur.create.form.jumlah}
onChange={(val) => {
stateDistribusiUmur.create.form.jumlah = Number(val || 0);
}}
min={0}
required
/>
<Select
label="Tahun"
placeholder="Pilih tahun"
data={yearOptions}
value={String(stateDistribusiUmur.create.form.tahun)}
onChange={(val) => {
stateDistribusiUmur.create.form.tahun = Number(val || currentYear);
}}
required
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default CreateDistribusiUmur;

View File

@@ -0,0 +1,284 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'
import colors from '@/con/colors';
import {
Box,
Button,
Center,
Flex,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title
} from '@mantine/core';
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../_com/header';
import { ModalKonfirmasiHapus } from '../../_com/modalKonfirmasiHapus';
import distribusiUmur from '../../_state/kependudukan/distribusi-umur';
function DistribusiUmurAdmin() {
const [search, setSearch] = useState('');
return (
<Box>
<HeaderSearch
title='Distribusi Umur'
placeholder='Cari rentang umur...'
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.currentTarget.value)}
/>
<ListDistribusiUmur search={search} />
</Box>
);
}
function ListDistribusiUmur({ search }: { search: string }) {
type DistribusiUmurType = {
id: string;
rentangUmur: string;
jumlah: number;
tahun: number;
};
const router = useRouter();
const stateDistribusiUmur = useProxy(distribusiUmur);
const [modalHapus, setModalHapus] = useState(false);
const [debouncedSearch] = useDebouncedValue(search, 1000);
const [selectedId, setSelectedId] = useState<string | null>(null);
const {
data,
page,
totalPages,
loading,
load,
} = stateDistribusiUmur.findMany;
const handleDelete = () => {
if (selectedId) {
stateDistribusiUmur.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
}
};
useShallowEffect(() => {
load(page, 10, debouncedSearch);
}, [page, debouncedSearch]);
const filteredData = data || [];
if (loading || !data) {
return (
<Stack py={10}>
<Skeleton height={600} radius="md" />
</Stack>
);
}
return (
<Box py={{ base: 'sm', md: 'md' }}>
<Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md">
<Group justify="space-between" mb={{ base: 'sm', md: 'md' }}>
<Title order={4} lh={{ base: 1.2, md: 1.15 }}>
List Distribusi Umur
</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/kependudukan/distribusi-umur/create')}
fz={{ base: 'sm', md: 'md' }}
>
Tambah Baru
</Button>
</Group>
{/* Desktop Table */}
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
<Table
highlightOnHover
miw={0}
style={{
tableLayout: 'fixed',
width: '100%',
}}
>
<TableThead>
<TableTr>
<TableTh style={{ width: '40%' }}>Rentang Umur</TableTh>
<TableTh style={{ width: '20%' }}>Jumlah</TableTh>
<TableTh style={{ width: '20%' }}>Tahun</TableTh>
<TableTh style={{ width: '10%' }}>Edit</TableTh>
<TableTh style={{ width: '10%' }}>Hapus</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item: DistribusiUmurType) => (
<TableTr key={item.id}>
<TableTd>{item.rentangUmur}</TableTd>
<TableTd>{item.jumlah.toLocaleString('id-ID')}</TableTd>
<TableTd>{item.tahun}</TableTd>
<TableTd>
<Button
variant="light"
color="green"
onClick={() =>
router.push(`/admin/kependudukan/distribusi-umur/${item.id}`)
}
fz="sm"
px="xs"
py="xs"
>
<IconEdit size={16} />
</Button>
</TableTd>
<TableTd>
<Button
variant="light"
color="red"
disabled={stateDistribusiUmur.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
fz="sm"
px="xs"
py="xs"
>
<IconTrash size={16} />
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={5}>
<Center py={20}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data distribusi umur yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
{/* Mobile Card */}
<Box hiddenFrom="md">
<Stack gap="xs">
{filteredData.length > 0 ? (
filteredData.map((item: DistribusiUmurType) => (
<Paper key={item.id} withBorder p="sm" radius="sm">
<Stack gap={"xs"}>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Rentang Umur
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.rentangUmur}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Jumlah
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.jumlah.toLocaleString('id-ID')}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Tahun
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.tahun}
</Text>
</Box>
<Group justify="flex-end" gap="xs">
<Button
variant="light"
color="green"
onClick={() =>
router.push(`/admin/kependudukan/distribusi-umur/${item.id}`)
}
fz="xs"
px="xs"
py="xs"
>
<IconEdit size={14} />
</Button>
<Button
variant="light"
color="red"
disabled={stateDistribusiUmur.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
fz="xs"
px="xs"
py="xs"
>
<IconTrash size={14} />
</Button>
</Group>
</Stack>
</Paper>
))
) : (
<Center py={20}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data distribusi umur yang cocok
</Text>
</Center>
)}
</Stack>
</Box>
</Paper>
{/* Pagination */}
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10, search);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
mt="md"
mb="md"
color="blue"
radius="md"
/>
</Center>
{/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleDelete}
text="Apakah anda yakin ingin menghapus data distribusi umur ini?"
/>
</Box>
);
}
export default DistribusiUmurAdmin;

View File

@@ -0,0 +1,267 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
Title,
TextInput,
Select,
Textarea
} from '@mantine/core';
import { DatePickerInput } from '@mantine/dates';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
import migrasiPenduduk from '../../../_state/kependudukan/migrasi-penduduk';
interface FormData {
jenis: string;
nama: string;
tanggal: string;
asalTujuan: string;
alasan: string;
jenisKelamin: string;
}
export default function EditMigrasiPenduduk() {
const router = useRouter();
const { id } = useParams() as { id: string };
const stateMigrasiPenduduk = useProxy(migrasiPenduduk);
const [isSubmitting, setIsSubmitting] = useState(false);
const [formData, setFormData] = useState<FormData>({
jenis: '',
nama: '',
tanggal: '',
asalTujuan: '',
alasan: '',
jenisKelamin: '',
});
const [originalData, setOriginalData] = useState<FormData>({
jenis: '',
nama: '',
tanggal: '',
asalTujuan: '',
alasan: '',
jenisKelamin: '',
});
const jenisOptions = [
{ value: 'MASUK', label: 'Masuk' },
{ value: 'KELUAR', label: 'Keluar' },
];
const jenisKelaminOptions = [
{ value: 'L', label: 'Laki-laki' },
{ value: 'P', label: 'Perempuan' },
];
const isFormValid = () => {
return (
formData.jenis?.trim() !== '' &&
formData.nama?.trim() !== '' &&
formData.tanggal?.trim() !== '' &&
formData.asalTujuan?.trim() !== ''
);
};
useEffect(() => {
if (!id) return;
const loadData = async () => {
try {
setIsSubmitting(true);
stateMigrasiPenduduk.update.id = id;
await stateMigrasiPenduduk.findUnique.load(id);
const data = stateMigrasiPenduduk.findUnique.data;
if (data) {
setFormData({
jenis: data.jenis ?? '',
nama: data.nama ?? '',
tanggal: data.tanggal ?? '',
asalTujuan: data.asalTujuan ?? '',
alasan: data.alasan ?? '',
jenisKelamin: data.jenisKelamin ?? '',
});
setOriginalData({
jenis: data.jenis ?? '',
nama: data.nama ?? '',
tanggal: data.tanggal ?? '',
asalTujuan: data.asalTujuan ?? '',
alasan: data.alasan ?? '',
jenisKelamin: data.jenisKelamin ?? '',
});
}
} catch (error) {
console.error('Error loading data:', error);
toast.error('Gagal memuat data');
} finally {
setIsSubmitting(false);
}
};
loadData();
}, [id]);
const handleChange = useCallback(
(field: keyof FormData) =>
(value: any) => {
const val = value || '';
setFormData((prev) => ({ ...prev, [field]: val }));
},
[]
);
const handleResetForm = () => {
setFormData({
jenis: originalData.jenis,
nama: originalData.nama,
tanggal: originalData.tanggal,
asalTujuan: originalData.asalTujuan,
alasan: originalData.alasan,
jenisKelamin: originalData.jenisKelamin,
});
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
stateMigrasiPenduduk.update.id = id;
stateMigrasiPenduduk.update.form = { ...formData };
await stateMigrasiPenduduk.update.submit();
toast.success('Data berhasil diperbarui');
router.push('/admin/kependudukan/migrasi-penduduk');
} catch (error) {
console.error('Error updating data:', error);
toast.error('Gagal memperbarui data');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'xs' }} py="xs">
{/* Header */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Edit Migrasi Penduduk
</Title>
</Group>
{/* Form Card */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<Select
label="Jenis Migrasi"
placeholder="Pilih jenis migrasi"
data={jenisOptions}
value={formData.jenis}
onChange={handleChange('jenis')}
required
/>
<TextInput
label="Nama"
placeholder="Masukkan nama lengkap"
value={formData.nama}
onChange={handleChange('nama')}
required
/>
<DatePickerInput
label="Tanggal"
placeholder="Pilih tanggal"
value={formData.tanggal ? new Date(formData.tanggal) : null}
onChange={(val: string | null) => {
setFormData((prev) => ({
...prev,
tanggal: val || '',
}));
}}
required
/>
<TextInput
label={formData.jenis === 'MASUK' ? 'Asal' : 'Tujuan'}
placeholder={formData.jenis === 'MASUK' ? 'Masukkan asal' : 'Masukkan tujuan'}
value={formData.asalTujuan}
onChange={handleChange('asalTujuan')}
required
/>
<Textarea
label="Alasan"
placeholder="Masukkan alasan (opsional)"
value={formData.alasan}
onChange={handleChange('alasan')}
autosize
minRows={2}
/>
<Select
label="Jenis Kelamin"
placeholder="Pilih jenis kelamin"
data={jenisKelaminOptions}
value={formData.jenisKelamin}
onChange={handleChange('jenisKelamin')}
/>
<Group justify="flex-end">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}

View File

@@ -0,0 +1,199 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Stack,
Title,
TextInput,
Select,
Textarea
} from '@mantine/core';
import { DatePickerInput } from '@mantine/dates';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import migrasiPenduduk from '../../../_state/kependudukan/migrasi-penduduk';
import { toast } from 'react-toastify';
function CreateMigrasiPenduduk() {
const stateMigrasiPenduduk = useProxy(migrasiPenduduk);
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const jenisOptions = [
{ value: 'MASUK', label: 'Masuk' },
{ value: 'KELUAR', label: 'Keluar' },
];
const jenisKelaminOptions = [
{ value: 'L', label: 'Laki-laki' },
{ value: 'P', label: 'Perempuan' },
];
const isFormValid = () => {
return (
stateMigrasiPenduduk.create.form.jenis?.trim() !== '' &&
stateMigrasiPenduduk.create.form.nama?.trim() !== '' &&
stateMigrasiPenduduk.create.form.tanggal?.trim() !== '' &&
stateMigrasiPenduduk.create.form.asalTujuan?.trim() !== ''
);
};
const resetForm = () => {
stateMigrasiPenduduk.create.form = {
jenis: '',
nama: '',
tanggal: '',
asalTujuan: '',
alasan: '',
jenisKelamin: '',
};
};
const handleSubmit = async () => {
try {
const id = await stateMigrasiPenduduk.create.create();
if (id) {
resetForm();
router.push('/admin/kependudukan/migrasi-penduduk');
}
} catch (error) {
console.error('Error creating migrasi penduduk:', error);
toast.error('Terjadi kesalahan saat menambah data migrasi penduduk');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 0, md: 'xs' }} py="xs">
{/* Header */}
<Group mb="md">
<Button
variant="subtle"
onClick={() => router.back()}
p="xs"
radius="md"
>
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<Title order={4} ml="sm" c="dark">
Tambah Migrasi Penduduk
</Title>
</Group>
{/* Form */}
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<Select
label="Jenis Migrasi"
placeholder="Pilih jenis migrasi"
data={jenisOptions}
value={stateMigrasiPenduduk.create.form.jenis}
onChange={(val) => {
stateMigrasiPenduduk.create.form.jenis = val || '';
}}
required
/>
<TextInput
label="Nama"
placeholder="Masukkan nama lengkap"
value={stateMigrasiPenduduk.create.form.nama}
onChange={(e) => {
stateMigrasiPenduduk.create.form.nama = e.currentTarget.value;
}}
required
/>
<DatePickerInput
label="Tanggal"
placeholder="Pilih tanggal"
value={stateMigrasiPenduduk.create.form.tanggal ? new Date(stateMigrasiPenduduk.create.form.tanggal) : null}
onChange={(val: string | null) => {
stateMigrasiPenduduk.create.form.tanggal = val || '';
}}
required
/>
<TextInput
label={stateMigrasiPenduduk.create.form.jenis === 'MASUK' ? 'Asal' : 'Tujuan'}
placeholder={stateMigrasiPenduduk.create.form.jenis === 'MASUK' ? 'Masukkan asal' : 'Masukkan tujuan'}
value={stateMigrasiPenduduk.create.form.asalTujuan}
onChange={(e) => {
stateMigrasiPenduduk.create.form.asalTujuan = e.currentTarget.value;
}}
required
/>
<Textarea
label="Alasan"
placeholder="Masukkan alasan (opsional)"
value={stateMigrasiPenduduk.create.form.alasan}
onChange={(e) => {
stateMigrasiPenduduk.create.form.alasan = e.currentTarget.value;
}}
autosize
minRows={2}
/>
<Select
label="Jenis Kelamin"
placeholder="Pilih jenis kelamin"
data={jenisKelaminOptions}
value={stateMigrasiPenduduk.create.form.jenisKelamin}
onChange={(val) => {
stateMigrasiPenduduk.create.form.jenisKelamin = val || '';
}}
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
disabled={!isFormValid() || isSubmitting}
style={{
background: !isFormValid() || isSubmitting
? `linear-gradient(135deg, #cccccc, #eeeeee)`
: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default CreateMigrasiPenduduk;

View File

@@ -0,0 +1,339 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
'use client'
import colors from '@/con/colors';
import {
Box,
Button,
Center,
Group,
Pagination,
Paper,
Skeleton,
Stack,
Table,
TableTbody,
TableTd,
TableTh,
TableThead,
TableTr,
Text,
Title
} from '@mantine/core';
import { useDebouncedValue, useShallowEffect } from '@mantine/hooks';
import { IconEdit, IconPlus, IconSearch, IconTrash } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../_com/header';
import { ModalKonfirmasiHapus } from '../../_com/modalKonfirmasiHapus';
import migrasiPenduduk from '../../_state/kependudukan/migrasi-penduduk';
function MigrasiPendudukAdmin() {
const [search, setSearch] = useState('');
return (
<Box>
<HeaderSearch
title='Migrasi Penduduk'
placeholder='Cari nama...'
searchIcon={<IconSearch size={20} />}
value={search}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.currentTarget.value)}
/>
<ListMigrasiPenduduk search={search} />
</Box>
);
}
function ListMigrasiPenduduk({ search }: { search: string }) {
type MigrasiPendudukType = {
id: string;
jenis: string;
nama: string;
tanggal: string;
asalTujuan: string;
alasan: string | null;
jenisKelamin: string | null;
};
const router = useRouter();
const stateMigrasiPenduduk = useProxy(migrasiPenduduk);
const [modalHapus, setModalHapus] = useState(false);
const [debouncedSearch] = useDebouncedValue(search, 1000);
const [selectedId, setSelectedId] = useState<string | null>(null);
const {
data,
page,
totalPages,
loading,
load,
} = stateMigrasiPenduduk.findMany;
const handleDelete = () => {
if (selectedId) {
stateMigrasiPenduduk.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
}
};
useShallowEffect(() => {
load(page, 10, debouncedSearch);
}, [page, debouncedSearch]);
const filteredData = data || [];
if (loading || !data) {
return (
<Stack py={10}>
<Skeleton height={600} radius="md" />
</Stack>
);
}
const formatTanggal = (tanggal: string) => {
try {
return new Date(tanggal).toLocaleDateString('id-ID', {
day: 'numeric',
month: 'long',
year: 'numeric',
});
} catch {
return tanggal;
}
};
return (
<Box py={{ base: 'sm', md: 'md' }}>
<Paper withBorder bg={colors['white-1']} p={{ base: 'md', md: 'lg' }} shadow="md" radius="md">
<Group justify="space-between" mb={{ base: 'sm', md: 'md' }}>
<Title order={4} lh={{ base: 1.2, md: 1.15 }}>
List Migrasi Penduduk
</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/kependudukan/migrasi-penduduk/create')}
fz={{ base: 'sm', md: 'md' }}
>
Tambah Baru
</Button>
</Group>
{/* Desktop Table */}
<Box visibleFrom="md" style={{ overflowX: 'auto' }}>
<Table
highlightOnHover
miw={0}
style={{
tableLayout: 'fixed',
width: '100%',
}}
>
<TableThead>
<TableTr>
<TableTh style={{ width: '10%' }}>Jenis</TableTh>
<TableTh style={{ width: '20%' }}>Nama</TableTh>
<TableTh style={{ width: '12%' }}>Tanggal</TableTh>
<TableTh style={{ width: '20%' }}>Asal/Tujuan</TableTh>
<TableTh style={{ width: '10%' }}>L/P</TableTh>
<TableTh style={{ width: '10%' }}>Edit</TableTh>
<TableTh style={{ width: '10%' }}>Hapus</TableTh>
</TableTr>
</TableThead>
<TableTbody>
{filteredData.length > 0 ? (
filteredData.map((item: MigrasiPendudukType) => (
<TableTr key={item.id}>
<TableTd>
<Text
fz="sm"
fw={500}
c={item.jenis === 'MASUK' ? 'green' : 'red'}
>
{item.jenis}
</Text>
</TableTd>
<TableTd>{item.nama}</TableTd>
<TableTd>{formatTanggal(item.tanggal)}</TableTd>
<TableTd>{item.asalTujuan}</TableTd>
<TableTd>{item.jenisKelamin || '-'}</TableTd>
<TableTd>
<Button
variant="light"
color="green"
onClick={() =>
router.push(`/admin/kependudukan/migrasi-penduduk/${item.id}`)
}
fz="sm"
px="xs"
py="xs"
>
<IconEdit size={16} />
</Button>
</TableTd>
<TableTd>
<Button
variant="light"
color="red"
disabled={stateMigrasiPenduduk.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
fz="sm"
px="xs"
py="xs"
>
<IconTrash size={16} />
</Button>
</TableTd>
</TableTr>
))
) : (
<TableTr>
<TableTd colSpan={7}>
<Center py={20}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data migrasi penduduk yang cocok
</Text>
</Center>
</TableTd>
</TableTr>
)}
</TableTbody>
</Table>
</Box>
{/* Mobile Card */}
<Box hiddenFrom="md">
<Stack gap="xs">
{filteredData.length > 0 ? (
filteredData.map((item: MigrasiPendudukType) => (
<Paper key={item.id} withBorder p="sm" radius="sm">
<Stack gap={"xs"}>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Jenis Migrasi
</Text>
<Text
fz="sm"
fw={500}
c={item.jenis === 'MASUK' ? 'green' : 'red'}
>
{item.jenis}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Nama
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.nama}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Tanggal
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{formatTanggal(item.tanggal)}
</Text>
</Box>
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Asal/Tujuan
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.asalTujuan}
</Text>
</Box>
{item.alasan && (
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Alasan
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.alasan}
</Text>
</Box>
)}
<Box>
<Text fz="sm" fw={600} lh={1.4}>
Jenis Kelamin
</Text>
<Text fz="sm" fw={500} lh={1.4}>
{item.jenisKelamin || '-'}
</Text>
</Box>
<Group justify="flex-end" gap="xs">
<Button
variant="light"
color="green"
onClick={() =>
router.push(`/admin/kependudukan/migrasi-penduduk/${item.id}`)
}
fz="xs"
px="xs"
py="xs"
>
<IconEdit size={14} />
</Button>
<Button
variant="light"
color="red"
disabled={stateMigrasiPenduduk.delete.loading}
onClick={() => {
setSelectedId(item.id);
setModalHapus(true);
}}
fz="xs"
px="xs"
py="xs"
>
<IconTrash size={14} />
</Button>
</Group>
</Stack>
</Paper>
))
) : (
<Center py={20}>
<Text c="dimmed" fz="sm" lh={1.4}>
Tidak ada data migrasi penduduk yang cocok
</Text>
</Center>
)}
</Stack>
</Box>
</Paper>
{/* Pagination */}
<Center>
<Pagination
value={page}
onChange={(newPage) => {
load(newPage, 10, search);
window.scrollTo({ top: 0, behavior: 'smooth' });
}}
total={totalPages}
mt="md"
mb="md"
color="blue"
radius="md"
/>
</Center>
{/* Modal Konfirmasi Hapus */}
<ModalKonfirmasiHapus
opened={modalHapus}
onClose={() => setModalHapus(false)}
onConfirm={handleDelete}
text="Apakah anda yakin ingin menghapus data migrasi penduduk ini?"
/>
</Box>
);
}
export default MigrasiPendudukAdmin;