Fix QC Kak Inno Admin, Fix QC Keano UI User, Fix QC Pak jun tabel apbdes

This commit is contained in:
2025-11-12 17:42:31 +08:00
parent 417a8937f5
commit 9622eb5a9a
354 changed files with 11444 additions and 4012 deletions

View File

@@ -5,10 +5,12 @@ import sdgsDesa from "@/app/admin/(dashboard)/_state/landing-page/sdgs-desa";
import colors from "@/con/colors";
import ApiFetch from "@/lib/api-fetch";
import {
ActionIcon,
Box,
Button,
Group,
Image,
Loader,
Paper,
Stack,
Text,
@@ -16,7 +18,7 @@ import {
Title
} from "@mantine/core";
import { Dropzone } from "@mantine/dropzone";
import { IconArrowBack, IconDeviceFloppy, IconPhoto, IconUpload, IconX } from "@tabler/icons-react";
import { IconArrowBack, IconPhoto, IconUpload, IconX } from "@tabler/icons-react";
import { useParams, useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
@@ -32,6 +34,15 @@ export default function EditKolaborasiInovasi() {
jumlah: "",
imageId: "",
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
name: "",
jumlah: "",
imageId: "",
imageUrl: "",
});
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
@@ -44,14 +55,21 @@ export default function EditKolaborasiInovasi() {
try {
const data = await sdgsState.edit.load(id);
if (data) {
setFormData({
// isi form awal
const newForm = {
name: data.name || "",
jumlah: data.jumlah || "",
imageId: data.imageId || "",
};
setFormData(newForm);
// simpan juga versi original
setOriginalData({
...newForm,
imageUrl: data.image?.link || "",
});
if (data.image?.link) {
setPreviewImage(data.image.link);
}
setPreviewImage(data.image?.link || null);
}
} catch (error) {
console.error("Error loading sdgs desa:", error);
@@ -62,12 +80,24 @@ export default function EditKolaborasiInovasi() {
loadKolaborasi();
}, [params?.id]);
const handleResetForm = () => {
setFormData({
name: originalData.name,
jumlah: originalData.jumlah,
imageId: originalData.imageId,
});
setPreviewImage(originalData.imageUrl || null);
setFile(null);
toast.info("Form dikembalikan ke data awal");
};
const handleInputChange = (field: keyof typeof formData, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
let imageId = formData.imageId;
// Upload file baru jika ada
@@ -83,19 +113,21 @@ export default function EditKolaborasiInovasi() {
await sdgsState.edit.update();
toast.success("sdgs desa berhasil diperbarui!");
router.push("/admin/landing-page/sdgs-desa");
router.push("/admin/landing-page/SDGs");
} catch (error) {
console.error("Error updating sdgs desa:", error);
toast.error("Terjadi kesalahan saat memperbarui sdgs desa");
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: "sm", md: "lg" }} py="md">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors["blue-button"]} size={24} />
</Button>
<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 Sdgs Desa
</Title>
@@ -112,7 +144,7 @@ export default function EditKolaborasiInovasi() {
<Stack gap="md">
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar Sdgs Desa
Gambar Program Inovasi
</Text>
<Dropzone
onDrop={(files) => {
@@ -122,15 +154,15 @@ export default function EditKolaborasiInovasi() {
setPreviewImage(URL.createObjectURL(selectedFile));
}
}}
onReject={() => toast.error("File tidak valid, gunakan format gambar")}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ "image/*": [] }}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload size={48} color={colors["blue-button"]} stroke={1.5} />
<IconUpload size={48} color={colors['blue-button']} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="red" stroke={1.5} />
@@ -143,25 +175,49 @@ export default function EditKolaborasiInovasi() {
Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar wajib
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
</Text>
</Stack>
</Group>
</Dropzone>
{/* ✅ Preview gambar + tombol X */}
{previewImage && (
<Box mt="sm" style={{ display: "flex", justifyContent: "center" }}>
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{ maxHeight: 220, objectFit: "contain", border: `1px solid ${colors["blue-button"]}` }}
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
loading="lazy"
/>
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
<TextInput
label="Nama Sdgs Desa"
placeholder="Masukkan nama Sdgs Desa"
@@ -180,19 +236,29 @@ export default function EditKolaborasiInovasi() {
/>
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
leftSection={<IconDeviceFloppy size={20} />}
loading={sdgsState.edit.loading}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors["blue-button"]}, #4facfe)`,
color: "#fff",
boxShadow: "0 4px 15px rgba(79, 172, 254, 0.4)",
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -27,7 +27,7 @@ function DetailSDGSDesa() {
sdgsState.delete.byId(selectedId)
setModalHapus(false)
setSelectedId(null)
router.push("/admin/landing-page/sdgs-desa")
router.push("/admin/landing-page/SDGs")
}
}
@@ -111,7 +111,7 @@ function DetailSDGSDesa() {
<Button
color="green"
onClick={() => router.push(`/admin/landing-page/sdgs-desa/${data.id}/edit`)}
onClick={() => router.push(`/admin/landing-page/SDGs/${data.id}/edit`)}
variant="light"
radius="md"
size="md"

View File

@@ -0,0 +1,221 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import { Box, Button, Group, Image, Paper, Stack, Text, TextInput, Title, Loader, ActionIcon } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
import sdgsDesa from '../../../_state/landing-page/sdgs-desa';
function CreateSDGsDesa() {
const router = useRouter();
const stateSDGSDesa = useProxy(sdgsDesa)
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
stateSDGSDesa.findMany.load();
}, []);
const resetForm = () => {
stateSDGSDesa.create.form = {
name: "",
jumlah: "",
imageId: "",
};
setFile(null);
setPreviewImage(null);
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
if (!file) {
return toast.warn("Pilih file image terlebih dahulu");
}
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
})
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error("Gagal mengupload file");
}
stateSDGSDesa.create.form.imageId = uploaded.id;
await stateSDGSDesa.create.create();
resetForm();
router.push("/admin/landing-page/SDGs")
} catch (error) {
console.error(error);
toast.error("Gagal menambahkan sdgs desa")
} finally {
setIsSubmitting(false);
}
}
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<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 Sdgs Desa
</Title>
</Group>
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar Program Inovasi
</Text>
<Dropzone
onDrop={(files) => {
const selectedFile = files[0];
if (selectedFile) {
setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile));
}
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload size={48} color={colors['blue-button']} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="red" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
<Stack gap="xs" align="center">
<Text size="md" fw={500}>
Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
</Text>
</Stack>
</Group>
</Dropzone>
{/* ✅ Preview gambar + tombol X */}
{previewImage && (
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
loading="lazy"
/>
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
<TextInput
label="Nama Sdgs Desa"
placeholder="Masukkan nama Sdgs Desa"
value={stateSDGSDesa.create.form.name}
onChange={(e) => {
stateSDGSDesa.create.form.name = e.currentTarget.value;
}}
required
/>
<TextInput
type="number"
label={
<Text fw="bold" fz="sm" mb={4}>
Jumlah
</Text>
}
placeholder="Masukkan jumlah"
value={stateSDGSDesa.create.form.jumlah}
onChange={(val) => {
stateSDGSDesa.create.form.jumlah = val.target.value;
}}
required
min={0}
radius="md"
/>
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `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 CreateSDGsDesa;

View File

@@ -63,7 +63,7 @@ function ListSdgsDesa({ search }: { search: string }) {
leftSection={<IconPlus size={18} />}
color={colors['blue-button']}
variant="light"
onClick={() => router.push('/admin/landing-page/sdgs-desa/create')}
onClick={() => router.push('/admin/landing-page/SDGs/create')}
>
Tambah Baru
</Button>
@@ -97,7 +97,7 @@ function ListSdgsDesa({ search }: { search: string }) {
<Group justify="space-between" mb="md">
<Title order={4}>Daftar Sdgs Desa</Title>
<Button leftSection={<IconPlus size={18} />} color="blue" variant="light"
onClick={() => router.push('/admin/landing-page/sdgs-desa/create')}
onClick={() => router.push('/admin/landing-page/SDGs/create')}
>
Tambah Baru
</Button>
@@ -131,7 +131,7 @@ function ListSdgsDesa({ search }: { search: string }) {
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() => router.push(`/admin/landing-page/sdgs-desa/${item.id}`)}
onClick={() => router.push(`/admin/landing-page/SDGs/${item.id}`)}
>
Detail
</Button>

View File

@@ -13,7 +13,9 @@ import {
Stack,
Text,
TextInput,
Title
Title,
Loader,
ActionIcon
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconFile, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
@@ -34,6 +36,17 @@ function EditAPBDes() {
fileId: ''
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
name: "",
jumlah: "",
imageId: "",
fileId: "",
imageUrl: "",
docUrl: "",
});
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [previewDoc, setPreviewDoc] = useState<string | null>(null);
const [imageFile, setImageFile] = useState<File | null>(null);
@@ -48,12 +61,21 @@ function EditAPBDes() {
try {
const data = await apbdesState.edit.load(id);
if (data) {
setFormData({
name: data.name || '',
jumlah: data.jumlah || '',
imageId: data.imageId || '',
fileId: data.fileId || ''
const newForm = {
name: data.name || "",
jumlah: data.jumlah || "",
imageId: data.imageId || "",
fileId: data.fileId || "",
};
setFormData(newForm);
// simpan juga versi original
setOriginalData({
...newForm,
imageUrl: data.image?.link || "",
docUrl: data.file?.link || "",
});
setPreviewImage(data.image?.link || null);
setPreviewDoc(data.file?.link || null);
}
@@ -82,6 +104,7 @@ function EditAPBDes() {
const handleSubmit = async () => {
try {
setIsSubmitting(true);
// Update global state with local form data first
apbdesState.edit.form = { ...apbdesState.edit.form, ...formData };
@@ -103,13 +126,29 @@ function EditAPBDes() {
await apbdesState.edit.update();
toast.success('APBDes berhasil diperbarui!');
router.push('/admin/landing-page/apbdes');
router.push('/admin/landing-page/APBDes');
} catch (err) {
console.error(err);
toast.error('Terjadi kesalahan saat memperbarui APBDes');
} finally {
setIsSubmitting(false);
}
};
const handleResetForm = () => {
setFormData({
name: originalData.name,
jumlah: originalData.jumlah,
imageId: originalData.imageId,
fileId: originalData.fileId,
});
setPreviewImage(originalData.imageUrl || null);
setImageFile(null);
setPreviewDoc(originalData.docUrl || null);
setDocFile(null);
toast.info("Form dikembalikan ke data awal");
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Group mb="md">
@@ -147,7 +186,7 @@ function EditAPBDes() {
onDrop={handleDrop('image')}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
@@ -157,13 +196,43 @@ function EditAPBDes() {
<Dropzone.Idle><IconPhoto size={48} color="#868e96" stroke={1.5} /></Dropzone.Idle>
<Stack gap="xs" align="center">
<Text size="md" fw={500}>Seret gambar atau klik untuk memilih file</Text>
<Text size="sm" c="dimmed">Maksimal 5MB, format gambar wajib</Text>
<Text size="sm" c="dimmed">Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp</Text>
</Stack>
</Group>
</Dropzone>
{previewImage && (
<Box mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
<Image src={previewImage} alt="Preview Gambar" radius="md" style={{ maxHeight: 300, objectFit: 'contain', border: `1px solid ${colors['blue-button']}` }} loading="lazy" />
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
loading="lazy"
/>
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setImageFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
@@ -196,16 +265,47 @@ function EditAPBDes() {
</Group>
</Dropzone>
{previewDoc && (
<Box mt="sm">
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Text size="sm" c="dimmed" mb="xs">Dokumen terpilih: {docFile?.name || 'Dokumen'}</Text>
<Button component="a" href={previewDoc} target="_blank" rel="noopener noreferrer" variant="light" leftSection={<IconFile size={16} />} size="sm">
Lihat Dokumen
</Button>
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewDoc(null);
setDocFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
<Group justify="right" mt="md">
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -216,7 +316,7 @@ function EditAPBDes() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -28,7 +28,7 @@ function DetailAPBDes() {
apbdesState.delete.byId(selectedId)
setModalHapus(false)
setSelectedId(null)
router.push("/admin/landing-page/apbdes")
router.push("/admin/landing-page/APBDes")
}
}
@@ -133,7 +133,7 @@ function DetailAPBDes() {
<Button
color="green"
onClick={() => router.push(`/admin/landing-page/apbdes/${data.id}/edit`)}
onClick={() => router.push(`/admin/landing-page/APBDes/${data.id}/edit`)}
variant="light"
radius="md"
size="md"

View File

@@ -11,7 +11,9 @@ import {
Stack,
Text,
TextInput,
Title
Title,
Loader,
ActionIcon
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconFile, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
@@ -29,7 +31,7 @@ function CreateAPBDes() {
const [previewDoc, setPreviewDoc] = useState<string | null>(null);
const [imageFile, setImageFile] = useState<File | null>(null);
const [docFile, setDocFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
@@ -51,40 +53,43 @@ function CreateAPBDes() {
if (!imageFile || !docFile) {
return toast.warn("Pilih gambar dan dokumen terlebih dahulu");
}
try {
setIsSubmitting(true);
const [uploadImageRes, uploadDocRes] = await Promise.all([
ApiFetch.api.fileStorage.create.post({ file: imageFile, name: imageFile.name }),
ApiFetch.api.fileStorage.create.post({ file: docFile, name: docFile.name }),
]);
const imageId = uploadImageRes?.data?.data?.id;
const fileId = uploadDocRes?.data?.data?.id;
if (!imageId || !fileId) {
return toast.error("Gagal mengupload file");
}
stateAPBDes.create.form.imageId = imageId;
stateAPBDes.create.form.fileId = fileId;
await stateAPBDes.create.create();
toast.success("Berhasil menambahkan APBDes");
resetForm();
router.push("/admin/landing-page/apbdes");
router.push("/admin/landing-page/APBDes");
} catch (error) {
console.error("Gagal submit:", error);
toast.error("Gagal menyimpan data");
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<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 APBDes
</Title>
@@ -102,7 +107,7 @@ function CreateAPBDes() {
{/* Gambar APBDes */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar APBDes
Gambar Program Inovasi
</Text>
<Dropzone
onDrop={(files) => {
@@ -114,40 +119,65 @@ function CreateAPBDes() {
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
<IconUpload size={48} color={colors['blue-button']} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
<IconX size={48} color="red" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
<IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
<Box>
<Text size="xl" inline>
<Stack gap="xs" align="center">
<Text size="md" fw={500}>
Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed" inline display="block" mt={7}>
Maksimal 5MB (format: JPEG, JPG, PNG, GIF, WEBP, SVG)
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
</Text>
</Box>
</Stack>
</Group>
</Dropzone>
{/* ✅ Preview gambar + tombol X */}
{previewImage && (
<Box mt="md" style={{ textAlign: 'center' }}>
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
loading="lazy"
/>
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setImageFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
@@ -197,7 +227,7 @@ function CreateAPBDes() {
</Dropzone>
{previewDoc && (
<Box mt="md">
<Box mt="md" pos="relative" style={{ textAlign: 'center' }}>
<Text fw="bold" fz="sm" mb={6}>
Pratinjau Dokumen
</Text>
@@ -207,6 +237,25 @@ function CreateAPBDes() {
height="500px"
style={{ border: '1px solid #ddd', borderRadius: '8px' }}
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewDoc(null);
setDocFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
@@ -215,19 +264,31 @@ function CreateAPBDes() {
<TextInput
label="Nama APBDes"
placeholder="Masukkan nama APBDes"
defaultValue={stateAPBDes.create.form.name || ''}
value={stateAPBDes.create.form.name || ''}
onChange={(e) => (stateAPBDes.create.form.name = e.target.value)}
required
/>
<TextInput
label="Jumlah Anggaran"
placeholder="14 M / 1 T / 200 JT / 900 RB"
defaultValue={stateAPBDes.create.form.jumlah || ''}
value={stateAPBDes.create.form.jumlah || ''}
onChange={(e) => (stateAPBDes.create.form.jumlah = e.target.value)}
required
/>
<Group justify="right" mt="md">
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -237,9 +298,8 @@ function CreateAPBDes() {
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
disabled={!imageFile || !docFile || !stateAPBDes.create.form.name || !stateAPBDes.create.form.jumlah}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -61,7 +61,7 @@ function ListAPBDes({ search }: { search: string }) {
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/landing-page/apbdes/create')}
onClick={() => router.push('/admin/landing-page/APBDes/create')}
>
Tambah Baru
</Button>
@@ -116,7 +116,7 @@ function ListAPBDes({ search }: { search: string }) {
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() => router.push(`/admin/landing-page/apbdes/${item.id}`)}
onClick={() => router.push(`/admin/landing-page/APBDes/${item.id}`)}
fullWidth
>
Detail

View File

@@ -1,9 +1,10 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import korupsiState from '@/app/admin/(dashboard)/_state/landing-page/desa-anti-korupsi';
import colors from '@/con/colors';
import { Box, Button, Group, Paper, Stack, TextInput, Title } from '@mantine/core';
import { Box, Button, Group, Paper, Stack, TextInput, Title, Loader } from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
@@ -14,71 +15,78 @@ export default function EditKategoriDesaAntiKorupsi() {
const router = useRouter();
const params = useParams();
const id = params?.id as string;
const stateKategori = useProxy(korupsiState.kategoriDesaAntiKorupsi);
// state lokal untuk form
// 🧠 Ambil proxy asli (bisa ditulis) & snapshot (buat render)
const stateKategori = korupsiState.kategoriDesaAntiKorupsi;
const snapshotKategori = useProxy(stateKategori);
// 🧾 state lokal form
const [formData, setFormData] = useState({ name: '' });
const [isLoading, setIsLoading] = useState(false);
const [originalData, setOriginalData] = useState({ name: '' });
const [isSubmitting, setIsSubmitting] = useState(false);
// load data kategori saat mount atau id berubah
// 📥 load data saat pertama kali dibuka
useEffect(() => {
if (!id) return;
const loadKategori = async () => {
setIsLoading(true);
try {
const data = await stateKategori.edit.load(id);
if (data) {
stateKategori.edit.id = id;
setFormData({ name: data.name || '' });
const newForm = { name: data.name || '' };
setFormData(newForm);
setOriginalData(newForm);
}
} catch (err) {
console.error(err);
toast.error('Gagal memuat data kategori desa anti korupsi');
} finally {
setIsLoading(false);
}
};
loadKategori();
}, [id]);
// handler controlled input
const handleChange = useCallback(
(field: keyof typeof formData, value: string) => {
setFormData(prev => ({ ...prev, [field]: value }));
},
[]
);
// ✍️ ubah value input form
const handleChange = (field: keyof typeof formData, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
// submit form
// 🔁 reset ke data awal
const handleResetForm = () => {
setFormData({ ...originalData });
toast.info('Form dikembalikan ke data awal');
};
// 💾 submit update
const handleSubmit = useCallback(async () => {
if (!formData.name.trim()) return toast.error('Nama kategori tidak boleh kosong');
setIsSubmitting(true);
try {
setIsLoading(true);
// update global state hanya saat submit
// isi form global dari local state
stateKategori.edit.form = { name: formData.name.trim() };
if (!stateKategori.edit.id) stateKategori.edit.id = id;
stateKategori.edit.id = id;
await stateKategori.edit.update();
toast.success('Kategori berhasil diperbarui');
router.push('/admin/landing-page/desa-anti-korupsi/kategori-desa-anti-korupsi');
} catch (err) {
console.error(err);
toast.error(err instanceof Error ? err.message : 'Gagal memperbarui kategori');
} finally {
setIsLoading(false);
setIsSubmitting(false);
}
}, [formData.name, id, router, stateKategori.edit]);
}, [formData.name, id, router]);
// 🧩 UI
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<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 Kategori Desa Anti Korupsi
</Title>
@@ -96,25 +104,36 @@ export default function EditKategoriDesaAntiKorupsi() {
<TextInput
label="Nama Kategori"
placeholder="Masukkan nama kategori"
value={formData.name} // controlled
value={formData.name}
onChange={(e) => handleChange('name', e.currentTarget.value)}
required
disabled={isLoading}
/>
<Group justify="right" mt="md">
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
loading={isLoading}
disabled={isSubmitting}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -1,16 +1,18 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import colors from '@/con/colors';
import { Box, Button, Group, Paper, Stack, TextInput, Title } from '@mantine/core';
import { Box, Button, Group, Paper, Stack, TextInput, Title, Loader } from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils';
import korupsiState from '../../../../_state/landing-page/desa-anti-korupsi';
import { toast } from 'react-toastify';
export default function CreateKategoriDesaAntiKorupsi() {
const router = useRouter();
const stateKategori = useProxy(korupsiState.kategoriDesaAntiKorupsi);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
stateKategori.findMany.load();
@@ -23,21 +25,29 @@ export default function CreateKategoriDesaAntiKorupsi() {
};
const handleSubmit = async () => {
if (!stateKategori.create.form.name) {
return alert('Nama kategori harus diisi');
setIsSubmitting(true);
try {
if (!stateKategori.create.form.name) {
return alert('Nama kategori harus diisi');
}
await stateKategori.create.create();
resetForm();
router.push("/admin/landing-page/desa-anti-korupsi/kategori-desa-anti-korupsi");
} catch (error) {
console.error("Error creating kategori desa anti korupsi:", error);
toast.error("Gagal menambahkan kategori desa anti korupsi");
} finally {
setIsSubmitting(false);
}
await stateKategori.create.create();
resetForm();
router.push("/admin/landing-page/desa-anti-korupsi/kategori-desa-anti-korupsi");
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<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 Kategori Desa Anti Korupsi
</Title>
@@ -55,12 +65,24 @@ export default function CreateKategoriDesaAntiKorupsi() {
<TextInput
label="Nama Kategori"
placeholder="Masukkan nama kategori"
defaultValue={stateKategori.create.form.name || ''}
value={stateKategori.create.form.name || ''}
onChange={(e) => (stateKategori.create.form.name = e.target.value)}
required
/>
<Group justify="right" mt="md">
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -71,7 +93,7 @@ export default function CreateKategoriDesaAntiKorupsi() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -1,7 +1,7 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import { Box, Button, Group, Paper, Select, Stack, Text, TextInput, Title } from '@mantine/core';
import { Loader, ActionIcon, Box, Button, Group, Paper, Select, Stack, Text, TextInput, Title } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack, IconFile, IconUpload, IconX } from '@tabler/icons-react';
@@ -34,9 +34,18 @@ export default function EditDesaAntiKorupsi() {
fileId: '',
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
name: "",
deskripsi: "",
kategoriId: "",
fileId: "",
fileUrl: ""
});
const [previewFile, setPreviewFile] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [isLoading, setIsLoading] = useState(false);
// Load kategori
useShallowEffect(() => {
@@ -63,6 +72,14 @@ export default function EditDesaAntiKorupsi() {
fileId: data.fileId,
});
setOriginalData({
name: data.name,
deskripsi: data.deskripsi,
kategoriId: data.kategoriId,
fileId: data.fileId,
fileUrl: data.file?.link || "",
});
if (data.file?.link) setPreviewFile(data.file.link);
} catch (err) {
console.error(err);
@@ -91,12 +108,24 @@ export default function EditDesaAntiKorupsi() {
setPreviewFile(URL.createObjectURL(selectedFile));
};
const handleResetForm = () => {
setFormData({
name: originalData.name,
deskripsi: originalData.deskripsi,
kategoriId: originalData.kategoriId,
fileId: originalData.fileId,
});
setPreviewFile(originalData.fileUrl || null);
setFile(null);
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => {
if (!formData.name) return toast.warn('Masukkan judul dokumen');
if (!formData.kategoriId) return toast.warn('Pilih kategori dokumen');
setIsLoading(true);
try {
setIsSubmitting(true);
// Update global state
desaAntiKorupsiState.edit.form = { ...desaAntiKorupsiState.edit.form, ...formData };
@@ -116,16 +145,16 @@ export default function EditDesaAntiKorupsi() {
console.error(err);
toast.error('Terjadi kesalahan saat memperbarui data');
} finally {
setIsLoading(false);
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<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 Desa Anti Korupsi
</Title>
@@ -204,7 +233,7 @@ export default function EditDesaAntiKorupsi() {
</Dropzone>
{previewFile && (
<Box mt="md">
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Text fw="bold" fz="sm" mb={6}>
Pratinjau Dokumen
</Text>
@@ -219,23 +248,52 @@ export default function EditDesaAntiKorupsi() {
>
<iframe src={previewFile} width="100%" height="100%" style={{ border: 'none' }} />
</Box>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewFile(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
<Group justify="right" mt="xl">
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
loading={isLoading}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -13,7 +13,9 @@ import {
Stack,
Text,
TextInput,
Title
Title,
Loader,
ActionIcon,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconFile, IconUpload, IconX } from '@tabler/icons-react';
@@ -27,7 +29,7 @@ export default function CreateDesaAntiKorupsi() {
const stateKorupsi = useProxy(korupsiState.desaAntikorupsi);
const [previewFile, setPreviewFile] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [isLoading, setIsLoading] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
stateKorupsi.findMany.load();
@@ -56,7 +58,7 @@ export default function CreateDesaAntiKorupsi() {
return toast.warn('Pilih kategori dokumen');
}
setIsLoading(true);
setIsSubmitting(true);
try {
const res = await ApiFetch.api.fileStorage.create.post({
file,
@@ -71,7 +73,7 @@ export default function CreateDesaAntiKorupsi() {
stateKorupsi.create.form.fileId = uploaded.id;
await stateKorupsi.create.create();
toast.success('Data berhasil disimpan');
resetForm();
router.push('/admin/landing-page/desa-anti-korupsi/list-desa-anti-korupsi');
@@ -79,15 +81,15 @@ export default function CreateDesaAntiKorupsi() {
console.error('Error:', error);
toast.error('Terjadi kesalahan saat menyimpan data');
} finally {
setIsLoading(false);
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<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 Dokumen Desa Anti Korupsi
</Title>
@@ -143,7 +145,7 @@ export default function CreateDesaAntiKorupsi() {
</Dropzone>
{previewFile && (
<Box mt="md" style={{ textAlign: 'center' }}>
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<iframe
src={previewFile}
width="100%"
@@ -154,6 +156,24 @@ export default function CreateDesaAntiKorupsi() {
maxWidth: '100%',
}}
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewFile(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
@@ -161,7 +181,7 @@ export default function CreateDesaAntiKorupsi() {
<TextInput
label="Judul Dokumen"
placeholder="Masukkan judul dokumen"
defaultValue={stateKorupsi.create.form.name || ''}
value={stateKorupsi.create.form.name || ''}
onChange={(e) => (stateKorupsi.create.form.name = e.target.value)}
required
/>
@@ -179,32 +199,52 @@ export default function CreateDesaAntiKorupsi() {
<Select
label="Kategori"
placeholder="Pilih kategori"
value={stateKorupsi.create.form.kategoriId || ''}
onChange={(val) => (stateKorupsi.create.form.kategoriId = val || '')}
data={
korupsiState.kategoriDesaAntiKorupsi.findMany.data?.map((v) => ({
value: v.id,
label: v.name,
})) || []
}
required
data={korupsiState.kategoriDesaAntiKorupsi.findMany.data?.map((item) => ({
label: item.name,
value: item.id,
})) || []}
value={stateKorupsi.create.form.kategoriId || null}
onChange={(val: string | null) => {
if (val) {
const selected = korupsiState.kategoriDesaAntiKorupsi.findMany.data?.find(
(item) => item.id === val
);
if (selected) {
stateKorupsi.create.form.kategoriId = selected.id;
}
} else {
stateKorupsi.create.form.kategoriId = '';
}
}}
searchable
clearable
nothingFoundMessage="Tidak ditemukan"
required
/>
<Group justify="right" mt="xl">
<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"
loading={isLoading}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -1,19 +1,21 @@
'use client'
/* eslint-disable react-hooks/exhaustive-deps */
/* eslint-disable @typescript-eslint/no-unused-vars */
'use client';
import indeksKepuasanState from '@/app/admin/(dashboard)/_state/landing-page/indeks-kepuasan';
import colors from '@/con/colors';
import {
Box,
Button,
Group,
Loader,
Paper,
Select,
Stack,
Text,
TextInput,
Title
Title,
} from '@mantine/core';
import { IconArrowBack, IconDeviceFloppy } from '@tabler/icons-react';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useCallback, useEffect, useState } from 'react';
import { toast } from 'react-toastify';
@@ -30,9 +32,13 @@ interface FormResponden {
function EditResponden() {
const router = useRouter();
const params = useParams() as { id: string };
const state = useProxy(indeksKepuasanState.responden);
const id = params.id;
// ✅ proxy asli untuk mutasi
const state = indeksKepuasanState.responden;
// ✅ snapshot untuk re-render (read-only)
const snapshot = useProxy(indeksKepuasanState.responden);
const [formData, setFormData] = useState<FormResponden>({
name: '',
tanggal: '',
@@ -41,31 +47,43 @@ function EditResponden() {
kelompokUmurId: '',
});
// Helper untuk load pilihan select
const [originalData, setOriginalData] = useState<FormResponden>({
name: '',
tanggal: '',
jenisKelaminId: '',
ratingId: '',
kelompokUmurId: '',
});
const [isSubmitting, setIsSubmitting] = useState(false);
// 🔹 Load data pilihan select
const loadSelectOptions = useCallback(() => {
indeksKepuasanState.jenisKelaminResponden.findMany.load();
indeksKepuasanState.pilihanRatingResponden.findMany.load();
indeksKepuasanState.kelompokUmurResponden.findMany.load();
}, []);
// Load data responden
// 🔹 Load data responden by ID
const loadResponden = useCallback(async () => {
if (!id) return;
try {
const data = await state.update.load(id);
if (!data) return;
setFormData({
name: data.name,
tanggal: data.tanggal,
jenisKelaminId: data.jenisKelaminId,
ratingId: data.ratingId,
kelompokUmurId: data.kelompokUmurId,
});
const newForm = {
name: data.name || '',
tanggal: data.tanggal || '',
jenisKelaminId: data.jenisKelaminId || '',
ratingId: data.ratingId || '',
kelompokUmurId: data.kelompokUmurId || '',
};
setFormData(newForm);
setOriginalData(newForm);
} catch (error) {
console.error("Error loading responden:", error);
toast.error("Gagal memuat data responden");
console.error('Error loading responden:', error);
toast.error('Gagal memuat data responden');
}
}, [id]);
@@ -74,14 +92,30 @@ function EditResponden() {
loadResponden();
}, [loadSelectOptions, loadResponden]);
// 🔹 Submit data
const handleSubmit = async () => {
state.update.id = id;
state.update.form = { ...formData }; // sinkronisasi manual
await state.update.submit();
router.push('/admin/landing-page/indeks-kepuasan-masyarakat/responden');
try {
setIsSubmitting(true);
state.update.id = id;
state.update.form = { ...formData }; // mutasi proxy asli ✅
await state.update.submit();
toast.success('Responden berhasil diperbarui!');
router.push('/admin/landing-page/indeks-kepuasan-masyarakat/responden');
} catch (error) {
console.error('Error updating responden:', error);
toast.error('Gagal memperbarui responden');
} finally {
setIsSubmitting(false);
}
};
// Reusable Select component
// 🔹 Reset form ke data awal
const handleResetForm = () => {
setFormData({ ...originalData });
toast.info('Form dikembalikan ke data awal');
};
// 🔹 Reusable Select component
const ControlledSelect = ({
label,
value,
@@ -98,30 +132,28 @@ function EditResponden() {
error?: string;
placeholder?: string;
loading?: boolean;
}) => {
return (
<Select
label={<Text fw="bold" fz="sm" mb={4}>{label}</Text>}
value={value}
onChange={(val) => onChange(val || '')}
data={options}
placeholder={placeholder}
disabled={loading}
clearable
searchable
required
radius="md"
error={error}
/>
);
};
}) => (
<Select
label={<Text fw="bold" fz="sm" mb={4}>{label}</Text>}
value={value}
onChange={(val) => onChange(val || '')}
data={options}
placeholder={placeholder}
disabled={loading}
clearable
searchable
required
radius="md"
error={error}
/>
);
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<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 Responden
</Title>
@@ -144,6 +176,7 @@ function EditResponden() {
radius="md"
required
/>
<TextInput
label="Tanggal"
type="date"
@@ -158,7 +191,6 @@ function EditResponden() {
value={formData.jenisKelaminId}
onChange={(val) => setFormData({ ...formData, jenisKelaminId: val })}
options={(indeksKepuasanState.jenisKelaminResponden.findMany.data || [])
.filter(Boolean)
.map((v) => ({ value: v.id || '', label: v.name || 'Tanpa Nama' }))}
loading={indeksKepuasanState.jenisKelaminResponden.findMany.loading}
error={!formData.jenisKelaminId ? 'Pilih jenis kelamin' : undefined}
@@ -169,7 +201,6 @@ function EditResponden() {
value={formData.ratingId}
onChange={(val) => setFormData({ ...formData, ratingId: val })}
options={(indeksKepuasanState.pilihanRatingResponden.findMany.data || [])
.filter(Boolean)
.map((v) => ({ value: v.id || '', label: v.name || 'Tanpa Nama' }))}
loading={indeksKepuasanState.pilihanRatingResponden.findMany.loading}
error={!formData.ratingId ? 'Pilih rating' : undefined}
@@ -180,23 +211,33 @@ function EditResponden() {
value={formData.kelompokUmurId}
onChange={(val) => setFormData({ ...formData, kelompokUmurId: val })}
options={(indeksKepuasanState.kelompokUmurResponden.findMany.data || [])
.filter(Boolean)
.map((v) => ({ value: v.id || '', label: v.name || 'Tanpa Nama' }))}
loading={indeksKepuasanState.kelompokUmurResponden.findMany.loading}
error={!formData.kelompokUmurId ? 'Pilih kelompok umur' : undefined}
/>
<Group justify="flex-end" mt="md">
<Button variant="light" color="red" onClick={() => router.back()}>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
<Button
leftSection={<IconDeviceFloppy size={20} />}
onClick={handleSubmit}
loading={state.update.loading}
color={colors['blue-button']}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -1,21 +1,20 @@
'use client'
/* eslint-disable @typescript-eslint/no-unused-vars */
/* eslint-disable @typescript-eslint/no-explicit-any */
import React from 'react';
import { useRouter } from 'next/navigation';
import grafikBerdasarkanJenisKelamin from '@/app/admin/(dashboard)/_state/ppid/indeks_kepuasan_masyarakat/grafikBerdasarkanJenisKelamin';
import { useProxy } from 'valtio/utils';
import { useState } from 'react';
import colors from '@/con/colors';
import { Box, Button, Paper, Stack, Title, TextInput, Select, Text } from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import indeksKepuasanState from '@/app/admin/(dashboard)/_state/landing-page/indeks-kepuasan';
import colors from '@/con/colors';
import { Box, Button, Group, Loader, Paper, Select, Stack, TextInput, Title } from '@mantine/core';
import { useShallowEffect } from '@mantine/hooks';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
function RespondenCreate() {
const router = useRouter();
const stategrafikBerdasarkanResponden = useProxy(indeksKepuasanState.responden)
const [donutData, setDonutData] = useState<any[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const resetForm = () => {
stategrafikBerdasarkanResponden.create.form = {
@@ -35,6 +34,7 @@ function RespondenCreate() {
})
const handleSubmit = async () => {
setIsSubmitting(true);
try {
const id = await stategrafikBerdasarkanResponden.create.create();
if (typeof id !== 'undefined') {
@@ -45,9 +45,11 @@ function RespondenCreate() {
}
}
resetForm();
router.push("/admin/ppid/ikm-desa-darmasaba/responden");
router.push("/admin/landing-page/indeks-kepuasan-masyarakat/responden");
} catch (error) {
console.error('Error submitting form:', error);
} finally {
setIsSubmitting(false);
}
}
return (
@@ -64,7 +66,7 @@ function RespondenCreate() {
label="Nama"
type='text'
placeholder="masukkan nama"
defaultValue={stategrafikBerdasarkanResponden.create.form.name}
value={stategrafikBerdasarkanResponden.create.form.name}
onChange={(val) => {
stategrafikBerdasarkanResponden.create.form.name = val.currentTarget.value;
}}
@@ -73,7 +75,7 @@ function RespondenCreate() {
label="Tanggal"
type="date"
placeholder="masukkan tanggal"
defaultValue={stategrafikBerdasarkanResponden.create.form.tanggal}
value={stategrafikBerdasarkanResponden.create.form.tanggal}
onChange={(val) => {
stategrafikBerdasarkanResponden.create.form.tanggal = val.currentTarget.value;
}}
@@ -96,24 +98,24 @@ function RespondenCreate() {
}
disabled={indeksKepuasanState.jenisKelaminResponden.findMany.loading}
/>
<Select
<Select
key={"rating_responden"}
label={"Rating"}
placeholder={indeksKepuasanState.pilihanRatingResponden.findMany.loading ? 'Memuat...' : 'Pilih rating'}
value={stategrafikBerdasarkanResponden.create.form.ratingId || ""}
onChange={(val) => {
stategrafikBerdasarkanResponden.create.form.ratingId = val ?? "";
}}
data={
(indeksKepuasanState.pilihanRatingResponden.findMany.data || [])
.filter(Boolean) // Hapus null, undefined, dll
.map((item) => ({
value: item.id,
label: item.name || 'Tanpa Nama',
}))
}
disabled={indeksKepuasanState.pilihanRatingResponden.findMany.loading}
/>
label={"Rating"}
placeholder={indeksKepuasanState.pilihanRatingResponden.findMany.loading ? 'Memuat...' : 'Pilih rating'}
value={stategrafikBerdasarkanResponden.create.form.ratingId || ""}
onChange={(val) => {
stategrafikBerdasarkanResponden.create.form.ratingId = val ?? "";
}}
data={
(indeksKepuasanState.pilihanRatingResponden.findMany.data || [])
.filter(Boolean) // Hapus null, undefined, dll
.map((item) => ({
value: item.id,
label: item.name || 'Tanpa Nama',
}))
}
disabled={indeksKepuasanState.pilihanRatingResponden.findMany.loading}
/>
<Select
key={"kelompokUmur"}
label={"Kelompok Umur"}
@@ -132,13 +134,32 @@ function RespondenCreate() {
}
disabled={indeksKepuasanState.kelompokUmurResponden.findMany.loading}
/>
<Button
mt={10}
bg={colors['blue-button']}
onClick={handleSubmit}
>
Submit
</Button>
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
style={{
background: `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

@@ -97,11 +97,11 @@ function ListResponden({ search }: ListRespondenProps) {
>
<TableThead>
<TableTr>
<TableTh style={{ width: '5%', textAlign: 'center' }}>No</TableTh>
<TableTh style={{ width: '25%', textAlign: 'center' }}>Nama</TableTh>
<TableTh style={{ width: '20%', textAlign: 'center' }}>Tanggal</TableTh>
<TableTh style={{ width: '20%', textAlign: 'center' }}>Jenis Kelamin</TableTh>
<TableTh style={{ width: '15%', textAlign: 'center' }}>Aksi</TableTh>
<TableTh style={{ width: '5%' }}>No</TableTh>
<TableTh style={{ width: '25%' }}>Nama</TableTh>
<TableTh style={{ width: '20%' }}>Tanggal</TableTh>
<TableTh style={{ width: '20%' }}>Jenis Kelamin</TableTh>
<TableTh style={{ width: '15%' }}>Aksi</TableTh>
</TableTr>
</TableThead>
<TableTbody>
@@ -116,9 +116,9 @@ function ListResponden({ search }: ListRespondenProps) {
) : (
filteredData.map((item, index) => (
<TableTr key={item.id}>
<TableTd ta="center">{index + 1}</TableTd>
<TableTd ta="center">{item.name}</TableTd>
<TableTd ta="center">
<TableTd>{index + 1}</TableTd>
<TableTd>{item.name}</TableTd>
<TableTd>
<Box w={150}>
{item.tanggal
? new Date(item.tanggal).toLocaleDateString('id-ID', {
@@ -129,12 +129,12 @@ function ListResponden({ search }: ListRespondenProps) {
: '-'}
</Box>
</TableTd>
<TableTd ta="center">
<TableTd>
<Box w={100}>
{item.jenisKelamin.name}
</Box>
</TableTd>
<TableTd ta="center">
<TableTd>
<Button
size="xs"
radius="md"

View File

@@ -3,7 +3,7 @@
import prestasiState from '@/app/admin/(dashboard)/_state/landing-page/prestasi-desa';
import colors from '@/con/colors';
import { Box, Button, Group, Paper, Stack, TextInput, Title } from '@mantine/core';
import { Box, Button, Group, Paper, Stack, TextInput, Title, Loader } from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
@@ -17,6 +17,8 @@ function EditKategoriPrestasi() {
const stateKategori = useProxy(prestasiState.kategoriPrestasi);
const [formData, setFormData] = useState({ name: '' });
const [isSubmitting, setIsSubmitting] = useState(false);
const [resetData, setResetData] = useState({ name: '' });
const [loading, setLoading] = useState(false);
// Load data kategori prestasi saat component mount
@@ -30,6 +32,7 @@ function EditKategoriPrestasi() {
if (data) {
stateKategori.edit.id = id;
setFormData({ name: data.name || '' });
setResetData({ name: data.name || '' });
}
} catch (err) {
console.error(err);
@@ -42,6 +45,13 @@ function EditKategoriPrestasi() {
loadKategori();
}, [id]);
const resetForm = () => {
setFormData({
name: resetData.name,
});
toast.info('Form dikembalikan ke data awal');
};
// Submit: update global state hanya saat submit
const handleSubmit = async () => {
if (!formData.name.trim()) {
@@ -50,6 +60,7 @@ function EditKategoriPrestasi() {
}
try {
setIsSubmitting(true);
stateKategori.edit.form = { name: formData.name.trim() };
stateKategori.edit.id ||= id; // fallback jika id belum ada
@@ -61,6 +72,8 @@ function EditKategoriPrestasi() {
} catch (err) {
console.error(err);
toast.error('Gagal memperbarui kategori prestasi desa');
} finally {
setIsSubmitting(false);
}
};
@@ -94,18 +107,29 @@ function EditKategoriPrestasi() {
/>
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
size="md"
loading={loading}
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -2,16 +2,18 @@
'use client'
import prestasiState from '@/app/admin/(dashboard)/_state/landing-page/prestasi-desa';
import colors from '@/con/colors';
import { Box, Button, Group, Paper, Stack, TextInput, Title } from '@mantine/core';
import { Box, Button, Group, Paper, Stack, TextInput, Title, Loader } from '@mantine/core';
import { IconArrowBack } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
function CreateKategoriPrestasi() {
const router = useRouter();
const stateKategori = useProxy(prestasiState.kategoriPrestasi)
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
stateKategori.findMany.load();
@@ -24,9 +26,17 @@ function CreateKategoriPrestasi() {
}
const handleSubmit = async () => {
await stateKategori.create.create();
resetForm();
router.push("/admin/landing-page/prestasi-desa/kategori-prestasi-desa")
try {
setIsSubmitting(true);
await stateKategori.create.create();
resetForm();
router.push("/admin/landing-page/prestasi-desa/kategori-prestasi-desa")
} catch (error) {
console.error("Error creating kategori prestasi:", error);
toast.error("Terjadi kesalahan saat menambahkan kategori prestasi");
} finally {
setIsSubmitting(false);
}
}
return (
@@ -52,12 +62,24 @@ function CreateKategoriPrestasi() {
<TextInput
label="Nama Kategori Prestasi"
placeholder="Masukkan nama kategori prestasi"
defaultValue={stateKategori.create.form.name || ''}
value={stateKategori.create.form.name || ''}
onChange={(val) => (stateKategori.create.form.name = val.target.value)}
required
/>
<Group justify="right" mt="md">
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -68,7 +90,7 @@ function CreateKategoriPrestasi() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -1,6 +1,6 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client';
import { Box, Button, Group, Image, Paper, Select, Stack, Text, TextInput, Title } from '@mantine/core';
import { ActionIcon, Box, Button, Group, Image, Paper, Select, Stack, Text, TextInput, Title, Loader } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useParams, useRouter } from 'next/navigation';
@@ -28,6 +28,17 @@ export default function EditPrestasiDesa() {
kategoriId: '',
imageId: '',
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
name: "",
deskripsi: "",
kategoriId: "",
imageId: "",
imageUrl: "",
});
const [previewFile, setPreviewFile] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const params = useParams();
@@ -59,6 +70,14 @@ export default function EditPrestasiDesa() {
imageId: data.imageId,
});
setOriginalData({
name: data.name,
deskripsi: data.deskripsi,
kategoriId: data.kategoriId,
imageId: data.imageId,
imageUrl: data.image?.link || "",
});
if (data.image?.link) setPreviewFile(data.image.link);
}
} catch (error) {
@@ -70,9 +89,22 @@ export default function EditPrestasiDesa() {
loadPrestasi();
}, [params?.id]);
const handleResetForm = () => {
setFormData({
name: originalData.name,
deskripsi: originalData.deskripsi,
kategoriId: originalData.kategoriId,
imageId: originalData.imageId,
});
setPreviewFile(originalData.imageUrl || null);
setFile(null);
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => {
try {
// Jika ada file baru, upload dulu
setIsSubmitting(true);
let imageId = formData.imageId;
if (file) {
const res = await ApiFetch.api.fileStorage.create.post({ file, name: file.name });
@@ -90,15 +122,17 @@ export default function EditPrestasiDesa() {
} catch (error) {
console.error('Error updating prestasi desa:', error);
toast.error('Terjadi kesalahan saat memperbarui prestasi desa');
} finally {
setIsSubmitting(false);
}
};
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<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 Prestasi Desa</Title>
</Group>
@@ -118,12 +152,29 @@ export default function EditPrestasiDesa() {
</Box>
<Select
label="Kategori"
placeholder="Pilih kategori"
value={formData.kategoriId}
onChange={(val) => setFormData({ ...formData, kategoriId: val ?? '' })}
data={prestasiState.kategoriPrestasi.findMany.data?.map((v) => ({ value: v.id, label: v.name })) || []}
required
label="Kategori"
placeholder="Pilih kategori"
data={prestasiState.kategoriPrestasi.findMany.data?.map((item) => ({
label: item.name,
value: item.id,
})) || []}
value={formData.kategoriId || null}
onChange={(val: string | null) => {
if (val) {
const selected = prestasiState.kategoriPrestasi.findMany.data?.find(
(item) => item.id === val
);
if (selected) {
setFormData({ ...formData, kategoriId: selected.id });
}
} else {
setFormData({ ...formData, kategoriId: '' });
}
}}
searchable
clearable
nothingFoundMessage="Tidak ditemukan"
required
/>
<Box>
@@ -138,7 +189,7 @@ export default function EditPrestasiDesa() {
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
@@ -148,13 +199,13 @@ export default function EditPrestasiDesa() {
<Dropzone.Idle><IconPhoto size={48} color="#868e96" stroke={1.5} /></Dropzone.Idle>
<Stack gap="xs" align="center">
<Text size="md" fw={500}>Seret gambar atau klik untuk memilih file</Text>
<Text size="sm" c="dimmed">Maksimal 5MB, format gambar wajib</Text>
<Text size="sm" c="dimmed">Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp</Text>
</Stack>
</Group>
</Dropzone>
{previewFile && (
<Box mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
<Box pos="relative" mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
<Image
src={previewFile}
alt="Preview Gambar"
@@ -162,11 +213,42 @@ export default function EditPrestasiDesa() {
style={{ maxHeight: 220, objectFit: 'contain', border: `1px solid ${colors['blue-button']}` }}
loading="lazy"
/>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewFile(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
<Group justify="right" mt="md">
{/* ======= Tombol Aksi ======= */}
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -177,7 +259,7 @@ export default function EditPrestasiDesa() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -4,7 +4,7 @@ import CreateEditor from '@/app/admin/(dashboard)/_com/createEditor';
import prestasiState from '@/app/admin/(dashboard)/_state/landing-page/prestasi-desa';
import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import { Box, Button, Group, Image, Paper, Select, Stack, Text, TextInput, Title } from '@mantine/core';
import { ActionIcon, Box, Button, Group, Image, Paper, Select, Stack, Text, TextInput, Title, Loader } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
@@ -15,18 +15,20 @@ import { useProxy } from 'valtio/utils';
function CreatePrestasiDesa() {
const router = useRouter();
const stateCreate = useProxy(prestasiState.prestasiDesa)
const snapPrestasi = useProxy(prestasiState.prestasiDesa); // snapshot → buat render
const prestasi = prestasiState.prestasiDesa
const [previewFile, setPreviewFile] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
stateCreate.findMany.load();
prestasi.findMany.load();
prestasiState.kategoriPrestasi.findMany.load();
}, []);
const resetForm = () => {
stateCreate.create.form = {
prestasi.create.form = {
name: "",
deskripsi: "",
kategoriId: "",
@@ -36,34 +38,42 @@ function CreatePrestasiDesa() {
setPreviewFile(null);
};
const handleSubmit = async () => {
if (!file) {
return toast.warn("Pilih file image terlebih dahulu");
try {
setIsSubmitting(true);
if (!file) {
return toast.warn("Pilih file image terlebih dahulu");
}
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
})
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error("Gagal mengupload image");
}
prestasi.create.form.imageId = uploaded.id;
await prestasi.create.create();
resetForm();
router.push("/admin/landing-page/prestasi-desa/list-prestasi-desa")
} catch (error) {
console.error(error);
toast.error("Gagal menambahkan prestasi desa");
} finally {
setIsSubmitting(false);
}
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
})
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error("Gagal mengupload image");
}
stateCreate.create.form.imageId = uploaded.id;
await stateCreate.create.create();
resetForm();
router.push("/admin/landing-page/prestasi-desa/list-prestasi-desa")
}
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Group mb="md">
<Button variant="subtle" onClick={() => router.back()} p="xs" radius="md">
<IconArrowBack color={colors['blue-button']} size={24} />
</Button>
<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 Prestasi Desa
</Title>
@@ -112,14 +122,14 @@ function CreatePrestasiDesa() {
Seret file gambar ke sini atau klik untuk memilih
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
Unggah file gambar (maks. 5MB)
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
</Text>
</div>
</Group>
</Dropzone>
{previewFile && (
<Box mt="md">
<Box mt="md" pos={"relative"}>
<Text size="sm" fw={500} mb={4}>
Pratinjau Gambar:
</Text>
@@ -132,6 +142,24 @@ function CreatePrestasiDesa() {
loading='lazy'
/>
</Box>
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewFile(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
@@ -139,8 +167,8 @@ function CreatePrestasiDesa() {
<TextInput
label="Judul Prestasi"
placeholder="Masukkan judul prestasi"
defaultValue={stateCreate.create.form.name}
onChange={(e) => (stateCreate.create.form.name = e.target.value)}
value={snapPrestasi.create.form.name}
onChange={(e) => (prestasi.create.form.name = e.target.value)}
required
/>
@@ -149,39 +177,62 @@ function CreatePrestasiDesa() {
Deskripsi Prestasi
</Text>
<CreateEditor
value={stateCreate.create.form.deskripsi}
onChange={(val) => (stateCreate.create.form.deskripsi = val)}
value={snapPrestasi.create.form.deskripsi}
onChange={(val) => (prestasi.create.form.deskripsi = val)}
/>
</Box>
<Select
label="Kategori Prestasi"
label="Kategori"
placeholder="Pilih kategori"
value={stateCreate.create.form.kategoriId}
onChange={(val) => (stateCreate.create.form.kategoriId = val ?? '')}
data={
prestasiState.kategoriPrestasi.findMany.data?.map((v) => ({
value: v.id,
label: v.name,
})) || []
}
data={prestasiState.kategoriPrestasi.findMany.data?.map((item) => ({
label: item.name,
value: item.id,
})) || []}
value={snapPrestasi.create.form.kategoriId || null}
onChange={(val: string | null) => {
if (val) {
const selected = prestasiState.kategoriPrestasi.findMany.data?.find(
(item) => item.id === val
);
if (selected) {
prestasi.create.form.kategoriId = selected.id;
}
} else {
prestasi.create.form.kategoriId = '';
}
}}
searchable
clearable
nothingFoundMessage="Tidak ditemukan"
required
/>
<Group justify="space-between" mt="md">
{/* ======= Tombol Aksi ======= */}
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="light"
variant="outline"
color="gray"
onClick={() => router.back()}
radius="md"
size="md"
onClick={resetForm}
>
Batal
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
bg={colors['blue-button']}
style={{ boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)' }}
radius="md"
size="md"
style={{
background: `linear-gradient(135deg, ${colors['blue-button']}, #4facfe)`,
color: '#fff',
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan Prestasi
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -1,10 +1,10 @@
/* eslint-disable react-hooks/exhaustive-deps */
'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 { useShallowEffect } from '@mantine/hooks';
import { IconDeviceImacCog, IconPlus, IconSearch } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { useState } from 'react';
import { useProxy } from 'valtio/utils';
import HeaderSearch from '../../../_com/header';
import prestasiState from '../../../_state/landing-page/prestasi-desa';
@@ -37,23 +37,9 @@ function ListPrestasi({ search }: { search: string }) {
load,
} = listState.findMany
// Debug log
console.log('ListPrestasi state:', {
loading,
data: data?.length,
page,
totalPages,
search
});
useEffect(() => {
console.log('Loading data...', { page, search });
load(page, 10, search).then(() => {
console.log('Data loaded:', listState.findMany.data);
}).catch(error => {
console.error('Error loading data:', error);
});
}, [page, search])
useShallowEffect(() => {
load(page, 10, search);
}, [page, search]);
const filteredData = data || []
@@ -70,14 +56,14 @@ function ListPrestasi({ search }: { search: string }) {
<Paper withBorder bg={colors['white-1']} p="lg" shadow="md" radius="md">
<Group justify="space-between" mb="md">
<Title order={4}>Daftar Prestasi Desa</Title>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/landing-page/prestasi-desa/list-prestasi-desa/create')}
>
Tambah Baru
</Button>
<Button
leftSection={<IconPlus size={18} />}
color="blue"
variant="light"
onClick={() => router.push('/admin/landing-page/prestasi-desa/list-prestasi-desa/create')}
>
Tambah Baru
</Button>
</Group>
<Box style={{ overflowX: 'auto' }}>
<Table highlightOnHover>
@@ -107,16 +93,16 @@ function ListPrestasi({ search }: { search: string }) {
</Box>
</TableTd>
<TableTd style={{ width: '25%', textAlign: 'center' }}>
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() => router.push(`/admin/landing-page/prestasi-desa/list-prestasi-desa/${item.id}`)}
>
Detail
</Button>
<Button
size="xs"
radius="md"
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() => router.push(`/admin/landing-page/prestasi-desa/list-prestasi-desa/${item.id}`)}
>
Detail
</Button>
</TableTd>
</TableTr>
))

View File

@@ -22,19 +22,19 @@ function LayoutTabs({ children }: { children: React.ReactNode }) {
{
label: "Program Inovasi",
value: "program-inovasi",
href: "/admin/landing-page/profile/program-inovasi",
href: "/admin/landing-page/profil/program-inovasi",
icon: <IconBulb size={18} stroke={1.8} />,
},
{
label: "Pejabat Desa",
value: "pejabat-desa",
href: "/admin/landing-page/profile/pejabat-desa",
href: "/admin/landing-page/profil/pejabat-desa",
icon: <IconUsers size={18} stroke={1.8} />,
},
{
label: "Media Sosial",
value: "media-sosial",
href: "/admin/landing-page/profile/media-sosial",
href: "/admin/landing-page/profil/media-sosial",
icon: <IconBrandFacebook size={18} stroke={1.8} />,
},
];

View File

@@ -4,6 +4,7 @@ import profileLandingPageState from '@/app/admin/(dashboard)/_state/landing-page
import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import {
ActionIcon,
Box,
Button,
Group,
@@ -12,7 +13,8 @@ import {
Stack,
Text,
TextInput,
Title
Title,
Loader
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
@@ -34,6 +36,15 @@ function EditMediaSosial() {
imageId: '',
});
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
name: "",
iconUrl: "",
imageId: "",
imageUrl: "",
});
// Load data by ID
useEffect(() => {
const id = params?.id as string;
@@ -44,12 +55,21 @@ function EditMediaSosial() {
const data = await stateMediaSosial.update.load(id);
if (data) {
setFormData({
name: data.name || '',
iconUrl: data.iconUrl || '',
imageId: data.imageId || '',
// isi form awal
const newForm = {
name: data.name || "",
iconUrl: data.iconUrl || "",
imageId: data.imageId || "",
};
setFormData(newForm);
// simpan juga versi original
setOriginalData({
...newForm,
imageUrl: data.image?.link || "",
});
if (data.image?.link) setPreviewImage(data.image.link);
setPreviewImage(data.image?.link || null);
}
} catch (error) {
console.error('Error loading media sosial:', error);
@@ -67,6 +87,7 @@ function EditMediaSosial() {
};
const handleSubmit = async () => {
setIsSubmitting(true);
try {
// update global state hanya saat submit
stateMediaSosial.update.form = { ...stateMediaSosial.update.form, ...formData };
@@ -82,13 +103,27 @@ function EditMediaSosial() {
await stateMediaSosial.update.update();
toast.success('Media sosial berhasil diperbarui!');
router.push('/admin/landing-page/profile/media-sosial');
router.push('/admin/landing-page/profil/media-sosial');
} catch (error) {
console.error('Error updating media sosial:', error);
toast.error('Terjadi kesalahan saat memperbarui media sosial');
} finally {
setIsSubmitting(false);
}
};
// ✅ Tombol Batal → balikin ke data original
const handleResetForm = () => {
setFormData({
name: originalData.name,
iconUrl: originalData.iconUrl,
imageId: originalData.imageId,
});
setPreviewImage(originalData.imageUrl || null);
setFile(null);
toast.info("Form dikembalikan ke data awal");
};
return (
<Box
px={{ base: 'sm', md: 'lg' }}
@@ -115,7 +150,7 @@ function EditMediaSosial() {
{/* Upload Gambar */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar Media Sosial
Gambar Program Inovasi
</Text>
<Dropzone
onDrop={(files) => {
@@ -127,7 +162,7 @@ function EditMediaSosial() {
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
@@ -146,25 +181,46 @@ function EditMediaSosial() {
Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar wajib
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
</Text>
</Stack>
</Group>
</Dropzone>
{/* ✅ Preview gambar + tombol X */}
{previewImage && (
<Box mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{
maxHeight: 220,
maxHeight: 200,
objectFit: 'contain',
border: `1px solid ${colors['blue-button']}`,
border: '1px solid #ddd',
}}
loading="lazy"
/>
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
@@ -187,7 +243,19 @@ function EditMediaSosial() {
required
/>
<Group justify="right">
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -198,7 +266,7 @@ function EditMediaSosial() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -25,7 +25,7 @@ function DetailMediaSosial() {
stateMediaSosial.delete.byId(selectedId);
setModalHapus(false);
setSelectedId(null);
router.push("/admin/landing-page/profile/media-sosial");
router.push("/admin/landing-page/profil/media-sosial");
}
};
@@ -110,7 +110,7 @@ function DetailMediaSosial() {
<Button
color="green"
onClick={() => router.push(`/admin/landing-page/profile/media-sosial/${data.id}/edit`)}
onClick={() => router.push(`/admin/landing-page/profil/media-sosial/${data.id}/edit`)}
variant="light"
radius="md"
size="md"

View File

@@ -11,7 +11,9 @@ import {
Stack,
Text,
TextInput,
Title
Title,
Loader,
ActionIcon
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
@@ -26,6 +28,7 @@ export default function CreateMediaSosial() {
const stateMediaSosial = useProxy(profileLandingPageState.mediaSosial);
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
stateMediaSosial.findMany.load();
@@ -42,27 +45,35 @@ export default function CreateMediaSosial() {
};
const handleSubmit = async () => {
if (!file) {
return toast.warn('Silakan pilih file gambar terlebih dahulu');
setIsSubmitting(true);
try {
if (!file) {
return toast.warn('Silakan pilih file gambar terlebih dahulu');
}
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error('Gagal mengunggah gambar, silakan coba lagi');
}
stateMediaSosial.create.form.imageId = uploaded.id;
await stateMediaSosial.create.create();
resetForm();
router.push('/admin/landing-page/profil/media-sosial');
} catch (error) {
console.error(error);
toast.error('Gagal menambahkan media sosial');
} finally {
setIsSubmitting(false);
}
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error('Gagal mengunggah gambar, silakan coba lagi');
}
stateMediaSosial.create.form.imageId = uploaded.id;
await stateMediaSosial.create.create();
resetForm();
router.push('/admin/landing-page/profile/media-sosial');
};
return (
@@ -87,7 +98,7 @@ export default function CreateMediaSosial() {
<Stack gap="md">
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar Media Sosial
Gambar Program Inovasi
</Text>
<Dropzone
onDrop={(files) => {
@@ -99,35 +110,65 @@ export default function CreateMediaSosial() {
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload size={48} color="var(--mantine-color-blue-6)" stroke={1.5} />
<IconUpload size={48} color={colors['blue-button']} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={48} color="var(--mantine-color-red-6)" stroke={1.5} />
<IconX size={48} color="red" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={48} color="var(--mantine-color-dimmed)" stroke={1.5} />
<IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
<Stack gap="xs" align="center">
<Text size="md" fw={500}>
Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
</Text>
</Stack>
</Group>
<Text ta="center" mt="sm" size="sm" color="dimmed">
Seret gambar atau klik untuk memilih file (maks 5MB)
</Text>
</Dropzone>
{/* ✅ Preview gambar + tombol X */}
{previewImage && (
<Box mt="sm" style={{ textAlign: 'center' }}>
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
loading="lazy"
/>
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
@@ -135,19 +176,28 @@ export default function CreateMediaSosial() {
<TextInput
label="Nama Media Sosial / Kontak"
placeholder="Masukkan nama media sosial atau kontak"
defaultValue={stateMediaSosial.create.form.name || ''}
value={stateMediaSosial.create.form.name || ''}
onChange={(e) => (stateMediaSosial.create.form.name = e.target.value)}
required
/>
<TextInput
label="Link Media Sosial / Nomor Telepon"
placeholder="Masukkan link media sosial atau nomor telepon"
defaultValue={stateMediaSosial.create.form.iconUrl || ''}
value={stateMediaSosial.create.form.iconUrl || ''}
onChange={(e) => (stateMediaSosial.create.form.iconUrl = e.target.value)}
required
/>
<Group justify="right">
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
<Button
onClick={handleSubmit}
radius="md"
@@ -158,7 +208,7 @@ export default function CreateMediaSosial() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -56,7 +56,7 @@ function ListMediaSosial({ search }: { search: string }) {
<Paper withBorder bg={colors['white-1']} p={'lg'} shadow="md" radius="md">
<Group justify="space-between" mb="md">
<Title order={4}>Daftar Media Sosial</Title>
<Button leftSection={<IconPlus size={18} />} color="blue" variant="light" onClick={() => router.push('/admin/landing-page/profile/media-sosial/create')}>
<Button leftSection={<IconPlus size={18} />} color="blue" variant="light" onClick={() => router.push('/admin/landing-page/profil/media-sosial/create')}>
Tambah Baru
</Button>
</Group>
@@ -100,7 +100,7 @@ function ListMediaSosial({ search }: { search: string }) {
variant="light"
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() => router.push(`/admin/landing-page/profile/media-sosial/${item.id}`)}
onClick={() => router.push(`/admin/landing-page/profil/media-sosial/${item.id}`)}
>
Detail
</Button>

View File

@@ -2,8 +2,9 @@
import colors from '@/con/colors';
import {
ActionIcon,
Alert, Box, Button, Center, Group, Image,
Paper, Stack, Text, TextInput, Title
Paper, Stack, Text, TextInput, Title, Loader
} from '@mantine/core';
import { useEffect, useState } from 'react';
import { useProxy } from 'valtio/utils';
@@ -24,7 +25,14 @@ function EditPejabatDesa() {
const [formData, setFormData] = useState({
name: '',
position: '',
imageId: null as string | null,
imageId: ''
});
const [originalData, setOriginalData] = useState({
name: '',
position: '',
imageId: "",
imageUrl: "",
});
// UI states
@@ -38,7 +46,7 @@ function EditPejabatDesa() {
const id = params?.id as string;
if (!id) {
toast.error("ID tidak valid");
router.push("/admin/landing-page/profile/pejabat-desa");
router.push("/admin/landing-page/profil/pejabat-desa");
return;
}
@@ -46,26 +54,27 @@ function EditPejabatDesa() {
const profileData = await profileLandingPageState.pejabatDesa.findUnique.load(id);
if (profileData) {
// Initialize form data
setFormData({
name: profileData.name || '',
position: profileData.position || '',
imageId: profileData.imageId || null,
// isi form awal
const newForm = {
name: profileData.name || "",
position: profileData.position || "",
imageId: profileData.imageId || "",
};
setFormData(newForm);
// simpan juga versi original
setOriginalData({
...newForm,
imageUrl: profileData.image?.link || "",
});
// Initialize edit state with profile data
profileLandingPageState.pejabatDesa.edit.initialize({
...profileData,
imageId: profileData.imageId || ''
});
if (profileData.image?.link) {
setPreviewImage(profileData.image.link);
}
setPreviewImage(profileData.image?.link || null);
}
} catch (error) {
console.error("Error loading profile:", error);
toast.error("Gagal memuat data profile");
console.error("Error loading program inovasi:", error);
toast.error(
error instanceof Error ? error.message : "Gagal mengambil data program inovasi"
);
}
};
@@ -78,22 +87,6 @@ function EditPejabatDesa() {
setFormData(prev => ({ ...prev, [field]: value }));
};
// Handle file change
const handleFileChange = (newFile: File | null) => {
if (!newFile) {
setFile(null);
return;
}
setFile(newFile);
const reader = new FileReader();
reader.onload = (event) => {
setPreviewImage(event.target?.result as string);
};
reader.readAsDataURL(newFile);
};
// Submit form
const handleSubmit = async () => {
if (isSubmitting || !formData.name.trim()) {
@@ -133,7 +126,7 @@ function EditPejabatDesa() {
const success = await profileLandingPageState.pejabatDesa.edit.submit();
if (success) {
toast.success("Berhasil menyimpan perubahan");
router.push("/admin/landing-page/profile/pejabat-desa");
router.push("/admin/landing-page/profil/pejabat-desa");
}
} catch (error) {
console.error("Error submitting form:", error);
@@ -143,6 +136,17 @@ function EditPejabatDesa() {
}
};
const handleResetForm = () => {
setFormData({
name: originalData.name,
position: originalData.position,
imageId: originalData.imageId,
});
setPreviewImage(originalData.imageUrl || null);
setFile(null);
toast.info("Form dikembalikan ke data awal");
};
const handleBack = () => router.back();
// Loading
@@ -216,49 +220,78 @@ function EditPejabatDesa() {
{/* File Upload */}
<Box>
<Text fz={"md"} fw={"bold"}>Gambar</Text>
<Text fw="bold" fz="sm" mb={6}>
Gambar Program Inovasi
</Text>
<Dropzone
onDrop={(files) => handleFileChange(files[0])}
onReject={() => toast.error('File tidak valid.')}
onDrop={(files) => {
const selectedFile = files[0];
if (selectedFile) {
setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile));
}
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: 'none' }}>
<Group justify="center" gap="xl" mih={180}>
<Dropzone.Accept>
<IconUpload size={52} color="var(--mantine-color-blue-6)" stroke={1.5} />
<IconUpload size={48} color={colors['blue-button']} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={52} color="var(--mantine-color-red-6)" stroke={1.5} />
<IconX size={48} color="red" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={52} color="var(--mantine-color-dimmed)" stroke={1.5} />
<IconPhoto size={48} color="#868e96" stroke={1.5} />
</Dropzone.Idle>
<div>
<Text size="xl" inline>
Drag gambar ke sini atau klik untuk pilih file
<Stack gap="xs" align="center">
<Text size="md" fw={500}>
Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed" inline mt={7}>
Maksimal 5MB dan harus format gambar
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
</Text>
</div>
</Stack>
</Group>
</Dropzone>
{/* ✅ Preview gambar + tombol X */}
{previewImage && (
<Box mt="sm">
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview"
alt="Preview Gambar"
radius="md"
style={{
maxWidth: '100%',
maxHeight: '150px',
maxHeight: 200,
objectFit: 'contain',
borderRadius: '8px',
border: '1px solid #ddd',
}}
loading="lazy"
/>
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
@@ -267,7 +300,7 @@ function EditPejabatDesa() {
<Box>
<Text fz="sm" fw="bold" mb="xs">Preview Gambar</Text>
{previewImage ? (
<Image alt="Profile preview" src={previewImage} w={200} h={200} fit="cover" radius="md" loading="lazy"/>
<Image alt="Profile preview" src={previewImage} w={200} h={200} fit="cover" radius="md" loading="lazy" />
) : (
<Center w={200} h={200} bg="gray.2">
<Stack align="center" gap="xs">
@@ -279,23 +312,33 @@ function EditPejabatDesa() {
</Box>
{/* Submit */}
<Group>
<Button
bg={colors['blue-button']}
onClick={handleSubmit}
loading={isSubmitting || allState.edit.loading}
disabled={!formData.name}
>
{isSubmitting ? 'Menyimpan...' : 'Simpan Perubahan'}
</Button>
<Group justify='right'>
<Button
variant="outline"
onClick={handleBack}
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
disabled={isSubmitting || allState.edit.loading}
>
Batal
</Button>
<Button
onClick={handleSubmit}
loading={isSubmitting || allState.edit.loading}
disabled={!formData.name}
radius="md"
size="md"
style={{
background: `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>

View File

@@ -40,7 +40,7 @@ function Page() {
variant="light"
leftSection={<IconEdit size={18} stroke={2} />}
radius="md"
onClick={() => router.push(`/admin/landing-page/profile/pejabat-desa/${allList.findUnique.data?.id}`)}
onClick={() => router.push(`/admin/landing-page/profil/pejabat-desa/${allList.findUnique.data?.id}`)}
>
Edit
</Button>

View File

@@ -5,6 +5,7 @@ import profileLandingPageState from '@/app/admin/(dashboard)/_state/landing-page
import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import {
ActionIcon,
Box,
Button,
Group,
@@ -13,7 +14,8 @@ import {
Stack,
Text,
TextInput,
Title
Title,
Loader
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
@@ -35,6 +37,15 @@ function EditProgramInovasi() {
imageId: "",
link: "",
})
const [isSubmitting, setIsSubmitting] = useState(false);
const [originalData, setOriginalData] = useState({
name: "",
description: "",
imageId: "",
link: "",
imageUrl: "",
});
useEffect(() => {
const id = params?.id as string;
@@ -44,19 +55,22 @@ function EditProgramInovasi() {
try {
const data = await stateProgramInovasi.update.load(id);
if (data) {
setFormData({
// isi form awal
const newForm = {
name: data.name || "",
description: data.description || "",
imageId: data.imageId || "",
link: data.link || ""
link: data.link || "",
};
setFormData(newForm);
// simpan juga versi original
setOriginalData({
...newForm,
imageUrl: data.image?.link || "",
});
// Preview image
if (data.image?.link) {
setPreviewImage(data.image.link);
} else {
setPreviewImage(null);
}
setPreviewImage(data.image?.link || null);
}
} catch (error) {
console.error("Error loading program inovasi:", error);
@@ -64,13 +78,26 @@ function EditProgramInovasi() {
error instanceof Error ? error.message : "Gagal mengambil data program inovasi"
);
}
}
};
loadProgramInovasi();
}, [params?.id]);
// ✅ Tombol Batal → balikin ke data original
const handleResetForm = () => {
setFormData({
name: originalData.name,
description: originalData.description,
imageId: originalData.imageId,
link: originalData.link,
});
setPreviewImage(originalData.imageUrl || null);
setFile(null);
toast.info("Form dikembalikan ke data awal");
};
const handleSubmit = async () => {
try {
setIsSubmitting(true);
// Upload file kalau ada file baru
let imageId = formData.imageId;
if (file) {
@@ -93,10 +120,12 @@ function EditProgramInovasi() {
await stateProgramInovasi.update.update();
toast.success("Program Inovasi berhasil diperbarui!");
router.push("/admin/landing-page/profile/program-inovasi");
router.push("/admin/landing-page/profil/program-inovasi");
} catch (error) {
console.error("Error updating program inovasi:", error);
toast.error("Terjadi kesalahan saat memperbarui program inovasi");
} finally {
setIsSubmitting(false);
}
};
@@ -134,7 +163,7 @@ function EditProgramInovasi() {
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
@@ -153,21 +182,46 @@ function EditProgramInovasi() {
Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar wajib
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
</Text>
</Stack>
</Group>
</Dropzone>
{/* ✅ Preview gambar + tombol X */}
{previewImage && (
<Box mt="sm" style={{ display: 'flex', justifyContent: 'center' }}>
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{ maxHeight: 220, objectFit: 'contain', border: `1px solid ${colors['blue-button']}` }}
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
loading="lazy"
/>
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
@@ -199,7 +253,20 @@ function EditProgramInovasi() {
onChange={(e) => setFormData({ ...formData, link: e.target.value })}
/>
{/* ======= Tombol Aksi ======= */}
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={handleResetForm}
>
Batal
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -210,7 +277,7 @@ function EditProgramInovasi() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -25,7 +25,7 @@ function DetailProgramInovasi() {
stateProgramInovasi.delete.byId(selectedId)
setModalHapus(false)
setSelectedId(null)
router.push("/admin/landing-page/profile/program-inovasi")
router.push("/admin/landing-page/profil/program-inovasi")
}
}
@@ -121,7 +121,7 @@ function DetailProgramInovasi() {
<Button
color="green"
onClick={() => router.push(`/admin/landing-page/profile/program-inovasi/${data.id}/edit`)}
onClick={() => router.push(`/admin/landing-page/profil/program-inovasi/${data.id}/edit`)}
variant="light"
radius="md"
size="md"

View File

@@ -12,7 +12,9 @@ import {
Stack,
Text,
TextInput,
Title
Title,
ActionIcon,
Loader,
} from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
@@ -27,45 +29,52 @@ function CreateProgramInovasi() {
const stateProgramInovasi = useProxy(profileLandingPageState.programInovasi);
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
stateProgramInovasi.findMany.load();
}, []);
// ✅ Reset semua input form & gambar
const resetForm = () => {
stateProgramInovasi.create.form = {
name: "",
description: "",
imageId: "",
link: "",
name: '',
description: '',
imageId: '',
link: '',
};
setPreviewImage(null);
setFile(null);
};
// ✅ Submit data
const handleSubmit = async () => {
if (!file) {
return toast.warn("Silakan pilih file gambar terlebih dahulu");
setIsSubmitting(true);
try {
if (!file) {
return toast.warn('Silakan pilih file gambar terlebih dahulu');
}
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error('Gagal mengunggah gambar, silakan coba lagi');
}
stateProgramInovasi.create.form.imageId = uploaded.id;
await stateProgramInovasi.create.create();
resetForm();
router.push('/admin/landing-page/profil/program-inovasi');
} catch (error) {
console.error(error);
toast.error('Gagal menambahkan program inovasi');
} finally {
setIsSubmitting(false);
}
};
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
});
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error("Gagal mengunggah gambar, silakan coba lagi");
}
stateProgramInovasi.create.form.imageId = uploaded.id;
await stateProgramInovasi.create.create();
resetForm();
router.push("/admin/landing-page/profile/program-inovasi")
}
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<Group mb="md">
@@ -86,6 +95,7 @@ function CreateProgramInovasi() {
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
{/* ======= Upload Gambar ======= */}
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar Program Inovasi
@@ -100,7 +110,7 @@ function CreateProgramInovasi() {
}}
onReject={() => toast.error('File tidak valid, gunakan format gambar')}
maxSize={5 * 1024 ** 2}
accept={{ 'image/*': [] }}
accept={{ 'image/*': ['.jpeg', '.jpg', '.png', '.webp'] }}
radius="md"
p="xl"
>
@@ -119,35 +129,64 @@ function CreateProgramInovasi() {
Seret gambar atau klik untuk memilih file
</Text>
<Text size="sm" c="dimmed">
Maksimal 5MB, format gambar wajib
Maksimal 5MB, format gambar .png, .jpg, .jpeg, webp
</Text>
</Stack>
</Group>
</Dropzone>
{/* ✅ Preview gambar + tombol X */}
{previewImage && (
<Box mt="sm" style={{ textAlign: 'center' }}>
<Box mt="sm" pos="relative" style={{ textAlign: 'center' }}>
<Image
src={previewImage}
alt="Preview Gambar"
radius="md"
style={{ maxHeight: 200, objectFit: 'contain', border: '1px solid #ddd' }}
style={{
maxHeight: 200,
objectFit: 'contain',
border: '1px solid #ddd',
}}
loading="lazy"
/>
{/* Tombol hapus (pojok kanan atas) */}
<ActionIcon
variant="filled"
color="red"
radius="xl"
size="sm"
pos="absolute"
top={5}
right={5}
onClick={() => {
setPreviewImage(null);
setFile(null);
}}
style={{
boxShadow: '0 2px 6px rgba(0,0,0,0.15)',
}}
>
<IconX size={14} />
</ActionIcon>
</Box>
)}
</Box>
{/* ======= Input Nama ======= */}
<TextInput
label="Nama Program Inovasi"
placeholder="Masukkan nama program inovasi"
defaultValue={stateProgramInovasi.create.form.name}
value={stateProgramInovasi.create.form.name}
onChange={(e) => (stateProgramInovasi.create.form.name = e.target.value)}
required
/>
{/* ======= Deskripsi ======= */}
<Box>
<Text fz={"sm"} fw={"bold"}>Deskripsi</Text>
<Text fz={'sm'} fw={'bold'}>
Deskripsi
</Text>
<CreateEditor
value={stateProgramInovasi.create.form.description || ''}
onChange={(htmlContent: string) => {
@@ -156,14 +195,28 @@ function CreateProgramInovasi() {
/>
</Box>
{/* ======= Link Program ======= */}
<TextInput
label="Link Program Inovasi"
placeholder="Masukkan link program inovasi (opsional)"
defaultValue={stateProgramInovasi.create.form.link || ''}
value={stateProgramInovasi.create.form.link || ''}
onChange={(e) => (stateProgramInovasi.create.form.link = e.target.value)}
/>
{/* ======= Tombol Aksi ======= */}
<Group justify="right">
{/* Tombol Batal */}
<Button
variant="outline"
color="gray"
radius="md"
size="md"
onClick={resetForm}
>
Reset
</Button>
{/* Tombol Simpan */}
<Button
onClick={handleSubmit}
radius="md"
@@ -174,7 +227,7 @@ function CreateProgramInovasi() {
boxShadow: '0 4px 15px rgba(79, 172, 254, 0.4)',
}}
>
Simpan
{isSubmitting ? <Loader size="sm" color="white" /> : 'Simpan'}
</Button>
</Group>
</Stack>

View File

@@ -56,7 +56,7 @@ function ListProgramInovasi({ search }: { search: string }) {
leftSection={<IconPlus size={18} />}
variant="light"
radius="md"
onClick={() => router.push('/admin/landing-page/profile/program-inovasi/create')}
onClick={() => router.push('/admin/landing-page/profil/program-inovasi/create')}
>
Tambah Program
</Button>
@@ -109,7 +109,7 @@ function ListProgramInovasi({ search }: { search: string }) {
color="blue"
leftSection={<IconDeviceImacCog size={16} />}
onClick={() =>
router.push(`/admin/landing-page/profile/program-inovasi/${item.id}`)
router.push(`/admin/landing-page/profil/program-inovasi/${item.id}`)
}
>
Detail

View File

@@ -1,191 +0,0 @@
/* eslint-disable react-hooks/exhaustive-deps */
'use client'
import colors from '@/con/colors';
import ApiFetch from '@/lib/api-fetch';
import { Box, Button, Group, Image, Paper, Stack, Text, TextInput, Title } from '@mantine/core';
import { Dropzone } from '@mantine/dropzone';
import { IconArrowBack, IconDeviceFloppy, IconPhoto, IconUpload, IconX } from '@tabler/icons-react';
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import { toast } from 'react-toastify';
import { useProxy } from 'valtio/utils';
import sdgsDesa from '../../../_state/landing-page/sdgs-desa';
function CreateSDGsDesa() {
const router = useRouter();
const stateSDGSDesa = useProxy(sdgsDesa)
const [previewImage, setPreviewImage] = useState<string | null>(null);
const [file, setFile] = useState<File | null>(null);
useEffect(() => {
stateSDGSDesa.findMany.load();
}, []);
const resetForm = () => {
stateSDGSDesa.create.form = {
name: "",
jumlah: "",
imageId: "",
};
setFile(null);
setPreviewImage(null);
};
const handleSubmit = async () => {
if (!file) {
return toast.warn("Pilih file image terlebih dahulu");
}
const res = await ApiFetch.api.fileStorage.create.post({
file,
name: file.name,
})
const uploaded = res.data?.data;
if (!uploaded?.id) {
return toast.error("Gagal mengupload file");
}
stateSDGSDesa.create.form.imageId = uploaded.id;
await stateSDGSDesa.create.create();
resetForm();
router.push("/admin/landing-page/sdgs-desa")
}
return (
<Box px={{ base: 'sm', md: 'lg' }} py="md">
<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 Sdgs Desa
</Title>
</Group>
<Paper
w={{ base: '100%', md: '50%' }}
bg={colors['white-1']}
p="lg"
radius="md"
shadow="sm"
style={{ border: '1px solid #e0e0e0' }}
>
<Stack gap="md">
<Box>
<Text fw="bold" fz="sm" mb={6}>
Gambar Sdgs Desa
</Text>
<Dropzone
onDrop={(files) => {
const selectedFile = files[0];
if (selectedFile) {
setFile(selectedFile);
setPreviewImage(URL.createObjectURL(selectedFile));
}
}}
onReject={() => toast.error('File tidak valid.')}
maxSize={5 * 1024 ** 2}
accept={{
'image/*': ['.jpeg', '.jpg', '.png', '.gif', '.webp', '.svg'],
}}
radius="md"
>
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: 'none' }}>
<Dropzone.Accept>
<IconUpload size={52} color={colors['blue-button']} stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={52} color="var(--mantine-color-red-6)" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconPhoto size={52} color="var(--mantine-color-dimmed)" stroke={1.5} />
</Dropzone.Idle>
<div style={{ textAlign: 'center' }}>
<Text size="xl" inline>
Drag file ke sini atau klik untuk memilih
</Text>
<Text size="sm" c="dimmed" inline mt={7} display="block">
Maksimal 5MB (JPEG, JPG, PNG, GIF, WEBP, SVG)
</Text>
</div>
</Group>
</Dropzone>
{previewImage && (
<Box mt="md">
<Text fw={500} fz="sm" mb={4}>
Pratinjau Gambar
</Text>
<Box
style={{
border: '1px solid #e0e0e0',
borderRadius: '8px',
overflow: 'hidden',
maxWidth: '300px'
}}
>
<Image
src={previewImage}
alt="Preview"
style={{ width: '100%', height: 'auto' }}
loading="lazy"
/>
</Box>
</Box>
)}
</Box>
<TextInput
label="Nama Sdgs Desa"
placeholder="Masukkan nama Sdgs Desa"
defaultValue={stateSDGSDesa.create.form.name}
onChange={(e) => {
stateSDGSDesa.create.form.name = e.currentTarget.value;
}}
required
/>
<TextInput
type="number"
label={
<Text fw="bold" fz="sm" mb={4}>
Jumlah
</Text>
}
placeholder="Masukkan jumlah"
defaultValue={stateSDGSDesa.create.form.jumlah}
onChange={(val) => {
stateSDGSDesa.create.form.jumlah = val.target.value;
}}
required
min={0}
radius="md"
/>
<Group justify="flex-end" mt="md">
<Button
variant="light"
color="red"
onClick={() => router.back()}
>
Batal
</Button>
<Button
leftSection={<IconDeviceFloppy size={20} />}
onClick={handleSubmit}
loading={stateSDGSDesa.create.loading}
color={colors['blue-button']}
>
Simpan
</Button>
</Group>
</Stack>
</Paper>
</Box>
);
}
export default CreateSDGsDesa;