amalia/07-jan-26 #102

Merged
amaliadwiy merged 3 commits from amalia/07-jan-26 into main 2026-01-07 17:11:51 +08:00
4 changed files with 330 additions and 18 deletions

View File

@@ -52,7 +52,10 @@ type FormSurat = {
syaratDokumen: DataItem[]; syaratDokumen: DataItem[];
}; };
type ErrorState = Record<string, string | null>;
export default function FormSurat() { export default function FormSurat() {
const [errors, setErrors] = useState<ErrorState>({});
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [noPengajuan, setNoPengajuan] = useState(""); const [noPengajuan, setNoPengajuan] = useState("");
const [submitLoading, setSubmitLoading] = useState(false); const [submitLoading, setSubmitLoading] = useState(false);
@@ -150,22 +153,24 @@ export default function FormSurat() {
}, [jenisSuratFix.id]); }, [jenisSuratFix.id]);
function onChecking() { function onChecking() {
const hasError = Object.values(errors).some((v) => v);
if (hasError) {
return notification({
title: "Gagal",
message: "Masih ada form yang belum valid",
type: "error",
});
}
const isFormKosong = Object.values(formSurat).some((value) => { const isFormKosong = Object.values(formSurat).some((value) => {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return ( return value.some(
value.length === 0 || (item) =>
value.some( typeof item.value === "string" && item.value.trim() === "",
(item) =>
typeof item.value === "string" && item.value.trim() === "",
)
); );
} }
return typeof value === "string" && value.trim() === "";
if (typeof value === "string") {
return value.trim() === "";
}
return false;
}); });
if (isFormKosong) { if (isFormKosong) {
@@ -174,9 +179,9 @@ export default function FormSurat() {
message: "Silahkan lengkapi form surat", message: "Silahkan lengkapi form surat",
type: "error", type: "error",
}); });
} else {
open();
} }
open();
} }
async function onSubmit() { async function onSubmit() {
@@ -239,6 +244,30 @@ export default function FormSurat() {
); );
} }
function validateField(key: string, value: any) {
const stringValue = String(value ?? "").trim();
// wajib diisi
if (!stringValue) {
return "Field wajib diisi";
}
// 🔥 semua key yang mengandung "nik"
if (key.toLowerCase().includes("nik")) {
if (!/^\d+$/.test(stringValue)) {
return "NIK harus berupa angka";
}
if (stringValue.length !== 16) {
return "NIK harus 16 digit";
}
}
return null;
}
function validationForm({ function validationForm({
key, key,
value, value,
@@ -246,12 +275,27 @@ export default function FormSurat() {
key: "nama" | "phone" | "dataPelengkap" | "syaratDokumen"; key: "nama" | "phone" | "dataPelengkap" | "syaratDokumen";
value: any; value: any;
}) { }) {
if (key == "dataPelengkap" || key == "syaratDokumen") { if (key === "dataPelengkap" || key === "syaratDokumen") {
const errorMsg = validateField(value.key, value.value);
setErrors((prev) => ({
...prev,
[value.key]: errorMsg,
}));
setFormSurat((prev) => ({ setFormSurat((prev) => ({
...prev, ...prev,
[key]: updateArrayByKey(prev[key], value.key, value.value), [key]: updateArrayByKey(prev[key], value.key, value.value),
})); }));
} else { } else {
const keyFix = key == "nama" ? "nama_kontak" : key;
const errorMsg = validateField(keyFix, value);
setErrors((prev) => ({
...prev,
[keyFix]: errorMsg,
}));
setFormSurat({ setFormSurat({
...formSurat, ...formSurat,
[key]: value, [key]: value,
@@ -259,6 +303,7 @@ export default function FormSurat() {
} }
} }
return ( return (
<Container size="md" w={"100%"} pb={"lg"}> <Container size="md" w={"100%"} pb={"lg"}>
<Modal <Modal
@@ -358,6 +403,7 @@ export default function FormSurat() {
label={<FieldLabel label="Nama" hint="Nama kontak" />} label={<FieldLabel label="Nama" hint="Nama kontak" />}
placeholder="Budi Setiawan" placeholder="Budi Setiawan"
value={formSurat.nama} value={formSurat.nama}
error={errors.nama_kontak}
onChange={(e) => onChange={(e) =>
validationForm({ key: "nama", value: e.target.value }) validationForm({ key: "nama", value: e.target.value })
} }
@@ -374,6 +420,8 @@ export default function FormSurat() {
} }
placeholder="08123456789" placeholder="08123456789"
value={formSurat.phone} value={formSurat.phone}
error={errors.phone}
type="number"
onChange={(e) => onChange={(e) =>
validationForm({ key: "phone", value: e.target.value }) validationForm({ key: "phone", value: e.target.value })
} }
@@ -446,6 +494,7 @@ export default function FormSurat() {
/> />
) : ( ) : (
<TextInput <TextInput
error={errors[item.key]}
type={item.type} type={item.type}
label={ label={
<FieldLabel <FieldLabel

View File

@@ -380,6 +380,7 @@ function DataUpdate({
}) { }) {
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const navigate = useNavigate(); const navigate = useNavigate();
const [errors, setErrors] = useState<Record<string, string | null>>({});
const [sukses, setSukses] = useState(false); const [sukses, setSukses] = useState(false);
const [submitLoading, setSubmitLoading] = useState(false); const [submitLoading, setSubmitLoading] = useState(false);
const [dataPelengkap, setDataPelengkap] = useState<DataItem[]>([]); const [dataPelengkap, setDataPelengkap] = useState<DataItem[]>([]);
@@ -427,6 +428,29 @@ function DataUpdate({
fetchData(); fetchData();
}, []); }, []);
function validateField(key: string, value: any) {
const stringValue = String(value ?? "").trim();
// wajib diisi
if (!stringValue) {
return "Field wajib diisi";
}
// 🔥 semua key yg mengandung "nik"
if (key.toLowerCase().includes("nik")) {
if (!/^\d+$/.test(stringValue)) {
return "NIK harus berupa angka";
}
if (stringValue.length !== 16) {
return "NIK harus 16 digit";
}
}
return null;
}
function upsertById<T extends { id: string }>(array: T[], item: T): T[] { function upsertById<T extends { id: string }>(array: T[], item: T): T[] {
const index = array.findIndex((v) => v.id === item.id); const index = array.findIndex((v) => v.id === item.id);
@@ -446,6 +470,13 @@ function DataUpdate({
kategori: "dataPelengkap" | "syaratDokumen"; kategori: "dataPelengkap" | "syaratDokumen";
value: UpdateDataItem; value: UpdateDataItem;
}) { }) {
const errorMsg = validateField(value.key, value.value);
setErrors((prev) => ({
...prev,
[value.id]: errorMsg,
}));
setFormSurat((prev) => ({ setFormSurat((prev) => ({
...prev, ...prev,
[kategori]: upsertById(prev[kategori], { [kategori]: upsertById(prev[kategori], {
@@ -456,6 +487,7 @@ function DataUpdate({
})); }));
} }
function updateArrayByKey( function updateArrayByKey(
list: UpdateDataItem[], list: UpdateDataItem[],
id: string, id: string,
@@ -465,6 +497,16 @@ function DataUpdate({
} }
function onChecking() { function onChecking() {
const hasError = Object.values(errors).some((v) => v);
if (hasError) {
return notification({
title: "Gagal",
message: "Masih ada data yang belum valid",
type: "error",
});
}
if ( if (
formSurat.dataPelengkap.length == 0 && formSurat.dataPelengkap.length == 0 &&
formSurat.syaratDokumen.length == 0 formSurat.syaratDokumen.length == 0
@@ -670,13 +712,15 @@ function DataUpdate({
(n: any) => n.key === item.key, (n: any) => n.key === item.key,
)?.value, )?.value,
) )
: parseTanggalID(item.value) : parseTanggalID(item.value)
} }
/> />
) : ( ) : (
<TextInput <TextInput
error={errors[item.id]}
label={<FieldLabel label={item.name} hint={item.desc} />} label={<FieldLabel label={item.name} hint={item.desc} />}
placeholder={item.name} placeholder={item.name}
type={item.type}
onChange={(e) => onChange={(e) =>
validationForm({ validationForm({
kategori: "dataPelengkap", kategori: "dataPelengkap",

View File

@@ -2,7 +2,9 @@ import ModalFile from "@/components/ModalFile";
import ModalSurat from "@/components/ModalSurat"; import ModalSurat from "@/components/ModalSurat";
import notification from "@/components/notificationGlobal"; import notification from "@/components/notificationGlobal";
import apiFetch from "@/lib/apiFetch"; import apiFetch from "@/lib/apiFetch";
import { parseTanggalID } from "@/server/lib/stringToDate";
import { import {
ActionIcon,
Anchor, Anchor,
Badge, Badge,
Button, Button,
@@ -14,24 +16,31 @@ import {
Group, Group,
List, List,
Modal, Modal,
Select,
Spoiler, Spoiler,
Stack, Stack,
Table, Table,
Text, Text,
Textarea, Textarea,
TextInput,
ThemeIcon, ThemeIcon,
Title, Title,
Tooltip
} from "@mantine/core"; } from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { useDisclosure, useShallowEffect } from "@mantine/hooks"; import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import { import {
IconAlignJustified, IconAlignJustified,
IconCheck, IconCheck,
IconEdit,
IconFileCertificate, IconFileCertificate,
IconFileCheck, IconFileCheck,
IconInfoCircle,
IconMessageReport, IconMessageReport,
IconPhone, IconPhone,
IconUser, IconUser,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import dayjs from "dayjs";
import type { User } from "generated/prisma"; import type { User } from "generated/prisma";
import type { JsonValue } from "generated/prisma/runtime/library"; import type { JsonValue } from "generated/prisma/runtime/library";
import _ from "lodash"; import _ from "lodash";
@@ -93,6 +102,7 @@ function DetailDataPengajuan({
dataText: any; dataText: any;
onAction: () => void; onAction: () => void;
}) { }) {
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const [catModal, setCatModal] = useState<"tolak" | "terima">("tolak"); const [catModal, setCatModal] = useState<"tolak" | "terima">("tolak");
const [keterangan, setKeterangan] = useState(""); const [keterangan, setKeterangan] = useState("");
@@ -103,6 +113,9 @@ function DetailDataPengajuan({
const [permissions, setPermissions] = useState<JsonValue[]>([]); const [permissions, setPermissions] = useState<JsonValue[]>([]);
const [viewImg, setViewImg] = useState({ file: "", folder: "" }); const [viewImg, setViewImg] = useState({ file: "", folder: "" });
const [uploading, setUploading] = useState({ ok: false, file: "" }); const [uploading, setUploading] = useState({ ok: false, file: "" });
const [editValue, setEditValue] = useState({ id: "", jenis: "", val: "", option: null as any, type: "", key: "" })
const [openEdit, setOpenEdit] = useState(false)
const [loadingUpdate, setLoadingUpdate] = useState(false)
useEffect(() => { useEffect(() => {
async function fetchHost() { async function fetchHost() {
@@ -215,6 +228,43 @@ function DetailDataPengajuan({
} }
}; };
async function updateDataText() {
try {
setLoadingUpdate(true)
const res = await apiFetch.api.pelayanan["update-data-pelengkap"].post({
id: editValue.id,
value: editValue.val,
jenis: editValue.key,
idUser: host?.id ?? "",
})
if (res?.status === 200) {
notification({
title: "Success",
message: "Success update data",
type: "success",
})
} else {
notification({
title: "Error",
message: "Failed to update data",
type: "error",
})
}
} catch (error) {
console.error(error)
notification({
title: "Error",
message: "Failed to update data",
type: "error",
})
} finally {
setLoadingUpdate(false)
setOpenEdit(false)
onAction()
}
}
useShallowEffect(() => { useShallowEffect(() => {
if (viewImg) { if (viewImg) {
setOpenedPreviewFile(true); setOpenedPreviewFile(true);
@@ -231,9 +281,86 @@ function DetailDataPengajuan({
} }
}, [uploading]); }, [uploading]);
function FieldLabel({ label, hint }: { label: string; hint?: string }) {
return (
<Group justify="apart" gap="xs" align="center">
<Text fw={600}>{label}</Text>
{hint && (
<Tooltip label={hint} withArrow>
<ActionIcon size={24} variant="subtle">
<IconInfoCircle size={16} />
</ActionIcon>
</Tooltip>
)}
</Group>
);
}
return ( return (
<> <>
{/* MODAL EDIT DATA PELENGKAP */}
<Modal
opened={openEdit}
onClose={() => setOpenEdit(false)}
title={"Edit"}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="ld">
{editValue.type == "enum" ? (
<Select
allowDeselect={false}
label={<FieldLabel label={editValue.jenis} />}
data={editValue.option ?? []}
placeholder={editValue.jenis}
onChange={(e) => { setEditValue({ ...editValue, val: e ?? "" }) }}
value={editValue.val}
/>
) : editValue.type == "date" ? (
<DateInput
locale="id"
valueFormat="DD MMMM YYYY"
label={<FieldLabel label={editValue.jenis} />}
placeholder={editValue.jenis}
onChange={(e) => {
const formatted = e
? dayjs(e).locale("id").format("DD MMMM YYYY")
: "";
setEditValue({
...editValue,
val: formatted
})
}}
value={
editValue.val
? parseTanggalID(editValue.val)
: parseTanggalID(editValue.val)
}
/>
) : (
<TextInput
label={<FieldLabel label={editValue.jenis} />}
placeholder={editValue.jenis}
type={editValue.type}
onChange={(e) => { setEditValue({ ...editValue, val: e.target.value }) }}
value={editValue.val}
/>
)}
<Group justify="center" grow>
<Button variant="light" onClick={() => { setOpenEdit(false) }}>
Batal
</Button>
<Button
variant="filled"
onClick={updateDataText}
disabled={loadingUpdate || !editValue.val}
loading={loadingUpdate}
>
Simpan
</Button>
</Group>
</Stack>
</Modal>
<ModalFile <ModalFile
open={openedPreviewFile && !_.isEmpty(viewImg.file)} open={openedPreviewFile && !_.isEmpty(viewImg.file)}
onClose={() => { onClose={() => {
@@ -413,7 +540,25 @@ function DetailDataPengajuan({
</Table.Td> </Table.Td>
<Table.Td>:</Table.Td> <Table.Td>:</Table.Td>
<Table.Td style={{ width: "85%" }}> <Table.Td style={{ width: "85%" }}>
{_.upperFirst(item.value)} <Flex
gap="md"
justify="flex-start"
align="center"
direction="row"
>
<Text>
{_.upperFirst(item.value)}
</Text>
<ActionIcon
variant="subtle"
aria-label="Edit"
onClick={() => {
setEditValue({ id: item.id, val: item.value, type: item.type, option: item.options, jenis: item.jenis, key: item.key })
setOpenEdit(true)
}}>
<IconEdit size={16} />
</ActionIcon>
</Flex>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
))} ))}

View File

@@ -308,7 +308,11 @@ const PelayananRoute = new Elysia({
}) })
const dataTextCategory = (data?.CategoryPelayanan?.dataPelengkap ?? []) as { const dataTextCategory = (data?.CategoryPelayanan?.dataPelengkap ?? []) as {
name: string; type: string;
options?: {
label: string,
value: string
}[]; name: string;
desc: string; desc: string;
key: string; key: string;
}[]; }[];
@@ -327,7 +331,10 @@ const PelayananRoute = new Elysia({
return { return {
id: item.id, id: item.id,
jenis: nama, jenis: nama,
key: ref?.key,
value: item.value, value: item.value,
type: ref?.type ?? "",
options: ref?.options ?? [],
order: ref?.order ?? Infinity, order: ref?.order ?? Infinity,
}; };
}) })
@@ -1050,6 +1057,73 @@ const PelayananRoute = new Elysia({
description: `tool untuk update data pengajuan pelayanan surat`, description: `tool untuk update data pengajuan pelayanan surat`,
} }
}) })
.post("/update-data-pelengkap", async ({ body }) => {
const { id, value, jenis, idUser } = body
const dataPelengkap = await prisma.dataTextPelayanan.findUnique({
where: {
id
},
select: {
idPengajuanLayanan: true,
PelayananAjuan: {
select: {
status: true
}
}
}
})
if (!dataPelengkap) {
return { success: false, message: 'data pelengkap surat tidak ditemukan' }
}
const upd = await prisma.dataTextPelayanan.update({
where: {
id
},
data: {
value: value,
}
})
const history = await prisma.historyPelayanan.create({
data: {
idPengajuanLayanan: dataPelengkap.idPengajuanLayanan,
deskripsi: `Pengajuan surat diupdate oleh user (data yg diupdate: ${jenis})`,
status: dataPelengkap.PelayananAjuan.status,
idUser
}
})
return { success: true, message: 'data pelengkap surat sudah diperbarui' }
}, {
body: t.Object({
id: t.String({
error: "id harus diisi",
description: "ID yang ingin diupdate"
}),
value: t.String({
error: "value harus diisi",
description: "Value yang ingin diupdate"
}),
jenis: t.String({
error: "jenis harus diisi",
description: "Jenis data yang ingin diupdate"
}),
idUser: t.String({
error: "idUser harus diisi",
description: "ID user yang melakukan update"
})
}),
detail: {
summary: "Update Data Pelengkap Pengajuan Pelayanan Surat oleh user admin",
description: `tool untuk update data pelengkap pengajuan pelayanan surat oleh user admin`,
}
})
.get("/list", async ({ query }) => { .get("/list", async ({ query }) => {
const { take, page, search, status } = query const { take, page, search, status } = query
const skip = !page ? 0 : (Number(page) - 1) * (!take ? 10 : Number(take)) const skip = !page ? 0 : (Number(page) - 1) * (!take ? 10 : Number(take))