Compare commits

...

27 Commits

Author SHA1 Message Date
9fed41cbe8 fix: testing surat 2026-01-13 17:10:59 +08:00
fc387fe8e6 fix: menu setting
deskiripsi:
- navigate
- list length

No Issues
2026-01-13 15:38:29 +08:00
80df579499 qc: nomer 2 dan 4
Deskripsi:
- button back
- breadcrumb
- active menu

No Issues
2026-01-13 15:10:08 +08:00
5bbbc15c27 Merge pull request 'fix: qc' (#108) from amalia/12-jan-26 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/108
2026-01-12 17:47:04 +08:00
82765f6ef0 fix: qc
Deskripsi:
- breadcumbs
- back
- active menu

nb: blm selesai

No Issues
2026-01-12 17:43:04 +08:00
e8b5720118 Merge pull request 'amalia/09-jan-26' (#107) from amalia/09-jan-26 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/107
2026-01-09 17:23:26 +08:00
01334ec573 upd: login
Deskripsi:
- update design login page

No Issues
2026-01-09 16:43:19 +08:00
98ad9b0d72 upd: loading saat melakukan aksi pada detail pengaduan
- mencegah 2x klik

NO Issues
2026-01-09 15:53:41 +08:00
c0471f47f3 upd: detail warga
Deskripsi:
- pagination pada list pengaduan dan list pengajuan surat
- search pada list pengaduan dan list pengajuan surat

No Issues
2026-01-09 15:46:30 +08:00
3d641d2035 Merge pull request 'upd: hapus console log' (#106) from amalia/08-jan-26 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/106
2026-01-08 17:38:19 +08:00
694115dbfb upd: hapus console log 2026-01-08 17:37:37 +08:00
7de5078868 Merge pull request 'upd: console log server2' (#105) from amalia/08-jan-26 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/105
2026-01-08 16:25:03 +08:00
7a3faa5719 upd: console log server2 2026-01-08 16:24:13 +08:00
ea5072d9ab Merge pull request 'upd: console log server' (#104) from amalia/08-jan-26 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/104
2026-01-08 16:03:03 +08:00
e8bb4f5a41 upd: console log server 2026-01-08 16:02:01 +08:00
d63bf024d3 Merge pull request 'upd: console log send wa' (#103) from amalia/08-jan-26 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/103
2026-01-08 15:50:37 +08:00
46f7dbf7bb upd: console log send wa 2026-01-08 15:49:51 +08:00
1adea29990 Merge pull request 'amalia/07-jan-26' (#102) from amalia/07-jan-26 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/102
2026-01-07 17:11:50 +08:00
2a5b6e7b7c fix: validasi form tambah pengajuan surat 2026-01-07 17:10:28 +08:00
2117612337 upd: pengajuan surat
Deskripsi:
- form edit data pelengkap

No Issues
2026-01-07 15:23:34 +08:00
8f33ec2ffa pelayanan surat
deskripsi:
- update validasi form tambah pengajuan layanan surat
- update validasi form update pengajuan layanan surat

No Issues
2026-01-07 12:02:27 +08:00
411f61ec15 Merge pull request 'amalia/06-jan-26' (#101) from amalia/06-jan-26 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/101
2026-01-06 17:45:44 +08:00
476319945e upd: validasi form create dan update pengajuan surat
Deskripsi:
- deselect false pada input select form tambah dan update pengajuan surat

No Issues
2026-01-06 17:44:16 +08:00
8480cec6ae upd: notif wa pengajian surat
Deskripsi:
- upload surat ke seafile
- update struktur db
- notif wa kirim link download surat
- api download surat

No Issues;
2026-01-06 17:00:08 +08:00
4ca5e4c4f3 Merge pull request 'upd: pengajuan surat' (#100) from amalia/05-jan-26 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/100
2026-01-05 17:25:27 +08:00
75758bcbe6 upd: pengajuan surat
Deskripsi:
- send wa penolakan + lik update
- send wa diterima
- upload ke seafile
- blm selesai ngirim link surat ke wa

No Issues
2026-01-05 17:24:53 +08:00
2d336ea467 Merge pull request 'amalia/02-jan-26' (#99) from amalia/02-jan-26 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/99
2026-01-02 17:32:32 +08:00
21 changed files with 1266 additions and 196 deletions

146
bak/ModalSurat.tsx.txt Normal file
View File

@@ -0,0 +1,146 @@
import apiFetch from "@/lib/apiFetch";
import { ActionIcon, Flex, Modal } from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks";
import { IconDownload, IconX } from "@tabler/icons-react";
import html2canvas from "html2canvas";
import jsPDF from "jspdf";
import { useRef } from "react";
import useSWR from "swr";
import SKBedaBiodataDiri from "./surat/SKBedaBiodataDiri";
import SKBelumKawin from "./surat/SKBelumKawin";
import SKDomisiliOrganisasi from "./surat/SKDomisiliOrganisasi";
import SKKelahiran from "./surat/SKKelahiran";
import SKKelakuanBaik from "./surat/SKKelakuanBaik";
import SKKematian from "./surat/SKKematian";
import SKPenghasilan from "./surat/SKPenghasilan";
import SKTempatUsaha from "./surat/SKTempatUsaha";
import SKTidakMampu from "./surat/SKTidakMampu";
import SKUsaha from "./surat/SKUsaha";
import SKYatim from "./surat/SKYatimPiatu";
export default function ModalSurat({
open,
onClose,
surat,
}: {
open: boolean;
onClose: () => void;
surat: string;
}) {
const A4Style = {
width: "210mm",
height: "297mm",
padding: "20mm",
background: "#fff",
color: "#000",
fontSize: "14px",
fontFamily: "Times New Roman",
};
const hiddenRef = useRef<any>(null);
const { data, mutate, isLoading } = useSWR("surat", () =>
apiFetch.api.surat.detail.get({
query: {
id: surat,
},
}),
);
useShallowEffect(() => {
mutate();
}, []);
const downloadPDF = async () => {
const element = hiddenRef.current;
const canvas = await html2canvas(element, {
scale: 2,
useCORS: true,
allowTaint: true,
width: element.offsetWidth,
height: element.offsetHeight,
});
const imgData = canvas.toDataURL("image/jpeg", 1.0);
const pdf = new jsPDF("p", "mm", "a4");
const pageWidth = 210; // A4 width mm
const pageHeight = 297; // A4 height mm
const imgWidth = pageWidth;
const imgHeight = (canvas.height * pageWidth) / canvas.width;
pdf.addImage(imgData, "JPEG", 0, 0, imgWidth, imgHeight);
pdf.save(`${data?.data?.surat?.nameCategory}.pdf`);
};
return (
<>
<Modal
opened={open}
onClose={() => onClose()}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
size="auto"
withCloseButton={false}
removeScrollProps={{ allowPinchZoom: true }}
styles={{
header: {
display: "flex",
justifyContent: "space-between",
alignItems: "center",
padding: "12px 16px",
},
title: {
width: "100%",
},
}}
title={
<Flex justify="space-between" align="center" w="100%">
<div style={{ fontSize: 18, fontWeight: 600 }}>Preview Surat</div>
<Flex gap={8}>
<ActionIcon size={32} variant="default">
<IconDownload size={20} onClick={downloadPDF} />
</ActionIcon>
<ActionIcon size={32} variant="default" onClick={onClose}>
<IconX size={20} />
</ActionIcon>
</Flex>
</Flex>
}
>
<div ref={hiddenRef} style={A4Style}>
{data && data.data ? (
data.data.surat.idCategory == "skusaha" ? (
<SKUsaha data={data.data} />
) : data.data.surat.idCategory == "skkelahiran" ? (
<SKKelahiran data={data.data} />
) : data.data.surat.idCategory == "skkelakuanbaik" ? (
<SKKelakuanBaik data={data.data} />
) : data.data.surat.idCategory == "skpenghasilan" ? (
<SKPenghasilan data={data.data} />
) : data.data.surat.idCategory == "sktidakmampu" ? (
<SKTidakMampu data={data.data} />
) : data.data.surat.idCategory == "skyatimpiatu" ? (
<SKYatim data={data.data} />
) : data.data.surat.idCategory == "skdomisiliorganisasi" ? (
<SKDomisiliOrganisasi data={data.data} />
) : data.data.surat.idCategory == "skbedabiodata" ? (
<SKBedaBiodataDiri data={data.data} />
) : data.data.surat.idCategory == "sktempatusaha" ? (
<SKTempatUsaha data={data.data} />
) : data.data.surat.idCategory == "skbelumkawin" ? (
<SKBelumKawin data={data.data} />
) : data.data.surat.idCategory == "skkematian" ? (
<SKKematian data={data.data} />
) : (
<></>
)
) : (
<></>
)}
</div>
</Modal>
</>
);
}

View File

@@ -187,6 +187,7 @@ model SuratPelayanan {
Warga Warga @relation(fields: [idWarga], references: [id])
idWarga String
noSurat String
file String?
dateExpired DateTime? @db.Date
status Int @default(0)
isActive Boolean @default(true)

View File

@@ -0,0 +1,44 @@
import { ActionIcon, Anchor, Breadcrumbs, Card, Group } from "@mantine/core";
import { IconChevronLeft } from "@tabler/icons-react";
import { useNavigate } from "react-router-dom";
export default function BreadCrumbs({ dataLink, back, linkBack }: { dataLink: { title: string, link: string, active: boolean }[], back?: boolean, linkBack?: string }) {
const navigate = useNavigate();
return (
<Card
radius="md"
p="sm"
withBorder
style={{
background:
"linear-gradient(145deg, rgba(25,25,25,0.95), rgba(45,45,45,0.85))",
borderColor: "rgba(100,100,100,0.2)",
boxShadow: "0 0 20px rgba(0,255,200,0.08)",
}}
>
<Group>
{
back &&
<ActionIcon variant="outline" aria-label="Settings" radius={"lg"} onClick={() => window.history.back()}>
<IconChevronLeft size={20} stroke={1.5} />
</ActionIcon>
}
<Breadcrumbs>
{
dataLink.map((item, index) => (
<Anchor
c={item.active ? "gray.0" : "gray.5"}
onClick={() => item.active || item.link == "#" ? null : navigate(item.link)}
key={index}
>
{item.title}
</Anchor>
))
}
</Breadcrumbs>
</Group>
</Card>
)
}

View File

@@ -207,7 +207,7 @@ export default function DesaSetting({
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{list?.map((v: any) => (
{list.length > 0 && list?.map((v: any) => (
<Table.Tr key={v.id}>
<Table.Td>{v.name}</Table.Td>
<Table.Td>

View File

@@ -4,19 +4,17 @@ import {
Button,
Divider,
Flex,
Grid,
Group,
Input,
List,
Modal,
Stack,
Table,
Text,
Title,
Tooltip,
Tooltip
} from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import { IconEdit, IconEye, IconPlus, IconTrash } from "@tabler/icons-react";
import { IconEye, IconTrash } from "@tabler/icons-react";
import type { JsonValue } from "generated/prisma/runtime/library";
import { useState } from "react";
import useSWR from "swr";
@@ -625,7 +623,7 @@ export default function KategoriPelayananSurat({
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{list?.map((v: any) => (
{list.length > 0 && list?.map((v: any) => (
<Table.Tr key={v.id}>
<Table.Td>{v.name}</Table.Td>
<Table.Td>

View File

@@ -1,10 +1,9 @@
import apiFetch from "@/lib/apiFetch";
import { ActionIcon, Flex, Modal } from "@mantine/core";
import { Flex, Loader, Modal, Text } from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks";
import { IconDownload, IconX } from "@tabler/icons-react";
import html2canvas from "html2canvas";
import jsPDF from "jspdf";
import { useRef } from "react";
import { useRef, useState } from "react";
import useSWR from "swr";
import SKBedaBiodataDiri from "./surat/SKBedaBiodataDiri";
import SKBelumKawin from "./surat/SKBelumKawin";
@@ -24,7 +23,7 @@ export default function ModalSurat({
surat,
}: {
open: boolean;
onClose: () => void;
onClose: (val: any) => void;
surat: string;
}) {
const A4Style = {
@@ -36,6 +35,7 @@ export default function ModalSurat({
fontSize: "14px",
fontFamily: "Times New Roman",
};
const [uploading, setUploading] = useState<"Menyiapkan" | "Mengupload" | "Selesai">("Menyiapkan")
const hiddenRef = useRef<any>(null);
const { data, mutate, isLoading } = useSWR("surat", () =>
apiFetch.api.surat.detail.get({
@@ -49,38 +49,80 @@ export default function ModalSurat({
mutate();
}, []);
const downloadPDF = async () => {
const element = hiddenRef.current;
const canvas = await html2canvas(element, {
scale: 2,
useCORS: true,
allowTaint: true,
width: element.offsetWidth,
height: element.offsetHeight,
});
const uploadPdf = async () => {
try {
if (data && data.data && data.data.surat && (data.data.surat.file == "" || data.data.surat.file == null)) {
setUploading("Mengupload");
const element = hiddenRef.current;
const canvas = await html2canvas(element, {
scale: 2,
useCORS: true,
allowTaint: true,
width: element.offsetWidth,
height: element.offsetHeight,
});
const imgData = canvas.toDataURL("image/jpeg", 1.0);
const imgData = canvas.toDataURL("image/jpeg", 1.0);
const pdf = new jsPDF("p", "mm", "a4");
const pageWidth = 210; // A4 width mm
const pageHeight = 297; // A4 height mm
const pdf = new jsPDF("p", "mm", "a4");
const pageWidth = 210; // A4 width mm
const pageHeight = 297; // A4 height mm
const imgWidth = pageWidth;
const imgHeight = (canvas.height * pageWidth) / canvas.width;
const imgWidth = pageWidth;
const imgHeight = (canvas.height * pageWidth) / canvas.width;
pdf.addImage(imgData, "JPEG", 0, 0, imgWidth, imgHeight);
pdf.addImage(imgData, "JPEG", 0, 0, imgWidth, imgHeight);
pdf.save(`${data?.data?.surat?.nameCategory}.pdf`);
};
// ⬇️ ambil sebagai Blob
const pdfBlob = pdf.output("blob");
const pdfFile = new File(
[pdfBlob],
`${data?.data?.surat?.nameCategory}.pdf`,
{
type: "application/pdf",
lastModified: Date.now(),
}
);
const resImg = await apiFetch.api.pengaduan.upload.post({
file: pdfFile,
folder: "surat",
});
const resUpdate = await apiFetch.api.surat.update.post({
id: surat,
filename: resImg.data?.filename!,
});
setUploading("Selesai");
setTimeout(() => {
onClose(resUpdate.data?.link);
}, 1000)
}
} catch (error) {
console.error("Error uploading PDF:", error);
}
}
useShallowEffect(() => {
if (open) {
setTimeout(() => {
uploadPdf();
}, 5000);
}
}, [surat, open]);
return (
<>
<Modal
opened={open}
onClose={() => onClose()}
onClose={() => { }}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
size="auto"
withCloseButton={false}
closeOnClickOutside={false}
removeScrollProps={{ allowPinchZoom: true }}
styles={{
header: {
@@ -97,14 +139,9 @@ export default function ModalSurat({
<Flex justify="space-between" align="center" w="100%">
<div style={{ fontSize: 18, fontWeight: 600 }}>Preview Surat</div>
<Flex gap={8}>
<ActionIcon size={32} variant="default">
<IconDownload size={20} onClick={downloadPDF} />
</ActionIcon>
<ActionIcon size={32} variant="default" onClick={onClose}>
<IconX size={20} />
</ActionIcon>
<Flex gap={8} align="center">
<Loader color="blue" size="xs" />
<Text size="sm">{uploading}</Text>
</Flex>
</Flex>
}

View File

@@ -1,12 +1,12 @@
import clientRoutes from "@/clientRoutes";
import {
Button,
Container,
Group,
Center,
Paper,
PasswordInput,
Stack,
Text,
TextInput,
TextInput
} from "@mantine/core";
import { useState } from "react";
import apiFetch from "../lib/apiFetch";
@@ -73,25 +73,73 @@ export default function Login() {
};
return (
<Container>
<Stack>
<Text>Login</Text>
<TextInput
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<PasswordInput
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Group justify="right">
<Button onClick={handleSubmit} disabled={loading}>
<Center
h="100vh"
style={{
background:
"radial-gradient(circle at top, #1f2d2b 0%, #0b0f0e 60%)",
}}
>
<Paper
radius="lg"
p="xl"
w={420}
style={{
background: "rgba(20, 20, 20, 0.75)",
backdropFilter: "blur(12px)",
border: "1px solid rgba(255, 255, 255, 0.08)",
boxShadow: "0 20px 60px rgba(0,0,0,0.6)",
}}
>
<Stack>
<Text
size="xl"
fw={700}
ta="center"
c="white"
>
Welcome Back
</Text>
<Text
size="sm"
ta="center"
c="dimmed"
>
Sign in to continue to your dashboard
</Text>
<TextInput
label="Email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
/>
<PasswordInput
label="Password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Button
fullWidth
mt="md"
radius="md"
size="md"
variant="gradient"
gradient={{ from: "teal", to: "cyan", deg: 45 }}
style={{
transition: "all 0.2s ease",
}}
onClick={handleSubmit}
disabled={loading}
>
Login
</Button>
</Group>
</Stack>
</Container>
</Stack>
</Paper>
</Center>
);
}

View File

@@ -52,7 +52,10 @@ type FormSurat = {
syaratDokumen: DataItem[];
};
type ErrorState = Record<string, string | null>;
export default function FormSurat() {
const [errors, setErrors] = useState<ErrorState>({});
const [opened, { open, close }] = useDisclosure(false);
const [noPengajuan, setNoPengajuan] = useState("");
const [submitLoading, setSubmitLoading] = useState(false);
@@ -150,22 +153,24 @@ export default function FormSurat() {
}, [jenisSuratFix.id]);
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) => {
if (Array.isArray(value)) {
return (
value.length === 0 ||
value.some(
(item) =>
typeof item.value === "string" && item.value.trim() === "",
)
return value.some(
(item) =>
typeof item.value === "string" && item.value.trim() === "",
);
}
if (typeof value === "string") {
return value.trim() === "";
}
return false;
return typeof value === "string" && value.trim() === "";
});
if (isFormKosong) {
@@ -174,9 +179,9 @@ export default function FormSurat() {
message: "Silahkan lengkapi form surat",
type: "error",
});
} else {
open();
}
open();
}
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({
key,
value,
@@ -246,12 +275,27 @@ export default function FormSurat() {
key: "nama" | "phone" | "dataPelengkap" | "syaratDokumen";
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) => ({
...prev,
[key]: updateArrayByKey(prev[key], value.key, value.value),
}));
} else {
const keyFix = key == "nama" ? "nama_kontak" : key;
const errorMsg = validateField(keyFix, value);
setErrors((prev) => ({
...prev,
[keyFix]: errorMsg,
}));
setFormSurat({
...formSurat,
[key]: value,
@@ -259,6 +303,7 @@ export default function FormSurat() {
}
}
return (
<Container size="md" w={"100%"} pb={"lg"}>
<Modal
@@ -324,6 +369,7 @@ export default function FormSurat() {
<Grid>
<Grid.Col span={12}>
<Select
allowDeselect={false}
label={
<FieldLabel
label="Jenis Surat"
@@ -357,6 +403,7 @@ export default function FormSurat() {
label={<FieldLabel label="Nama" hint="Nama kontak" />}
placeholder="Budi Setiawan"
value={formSurat.nama}
error={errors.nama_kontak}
onChange={(e) =>
validationForm({ key: "nama", value: e.target.value })
}
@@ -373,6 +420,8 @@ export default function FormSurat() {
}
placeholder="08123456789"
value={formSurat.phone}
error={errors.phone}
type="number"
onChange={(e) =>
validationForm({ key: "phone", value: e.target.value })
}
@@ -396,6 +445,7 @@ export default function FormSurat() {
<Grid.Col span={6} key={index}>
{item.type == "enum" ? (
<Select
allowDeselect={false}
label={
<FieldLabel
label={item.name}
@@ -430,8 +480,8 @@ export default function FormSurat() {
onChange={(e) => {
const formatted = e
? dayjs(e)
.locale("id")
.format("DD MMMM YYYY")
.locale("id")
.format("DD MMMM YYYY")
: "";
validationForm({
key: "dataPelengkap",
@@ -444,6 +494,7 @@ export default function FormSurat() {
/>
) : (
<TextInput
error={errors[item.key]}
type={item.type}
label={
<FieldLabel

View File

@@ -380,6 +380,7 @@ function DataUpdate({
}) {
const [opened, { open, close }] = useDisclosure(false);
const navigate = useNavigate();
const [errors, setErrors] = useState<Record<string, string | null>>({});
const [sukses, setSukses] = useState(false);
const [submitLoading, setSubmitLoading] = useState(false);
const [dataPelengkap, setDataPelengkap] = useState<DataItem[]>([]);
@@ -427,6 +428,29 @@ function DataUpdate({
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[] {
const index = array.findIndex((v) => v.id === item.id);
@@ -446,6 +470,13 @@ function DataUpdate({
kategori: "dataPelengkap" | "syaratDokumen";
value: UpdateDataItem;
}) {
const errorMsg = validateField(value.key, value.value);
setErrors((prev) => ({
...prev,
[value.id]: errorMsg,
}));
setFormSurat((prev) => ({
...prev,
[kategori]: upsertById(prev[kategori], {
@@ -456,6 +487,7 @@ function DataUpdate({
}));
}
function updateArrayByKey(
list: UpdateDataItem[],
id: string,
@@ -465,6 +497,16 @@ function DataUpdate({
}
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 (
formSurat.dataPelengkap.length == 0 &&
formSurat.syaratDokumen.length == 0
@@ -625,6 +667,7 @@ function DataUpdate({
<Grid.Col span={6} key={index}>
{item.type == "enum" ? (
<Select
allowDeselect={false}
label={<FieldLabel label={item.name} hint={item.desc} />}
data={item.options ?? []}
placeholder={item.name}
@@ -669,13 +712,15 @@ function DataUpdate({
(n: any) => n.key === item.key,
)?.value,
)
: parseTanggalID(item.value)
: parseTanggalID(item.value)
}
/>
) : (
<TextInput
error={errors[item.id]}
label={<FieldLabel label={item.name} hint={item.desc} />}
placeholder={item.name}
type={item.type}
onChange={(e) =>
validationForm({
kategori: "dataPelengkap",

View File

@@ -73,7 +73,7 @@ export default function DashboardLayout() {
<AppShell
padding="lg"
navbar={{
width: 260,
width: 300,
breakpoint: "sm",
collapsed: { mobile: !opened, desktop: !opened },
}}
@@ -290,22 +290,31 @@ function NavigationDashboard() {
.map((item) => (
<NavLink
key={item.path}
active={isActive(item.path as keyof typeof clientRoute)}
active={isActive(item.path as keyof typeof clientRoute) ||
(location.pathname == "/scr/dashboard/pelayanan-surat/detail-pelayanan" && item.path == "/scr/dashboard/pelayanan-surat/list-pelayanan") ||
(location.pathname == "/scr/dashboard/pengaduan/detail" && item.path == "/scr/dashboard/pengaduan/list") ||
(location.pathname == "/scr/dashboard/warga/detail-warga" && item.path == "/scr/dashboard/warga/list-warga")}
leftSection={item.icon}
label={
<Flex align="center" gap={6}>
<Text fw={500}>{item.label}</Text>
{isActive(item.path as keyof typeof clientRoute) && (
<Badge
variant="light"
color="teal"
radius="sm"
size="xs"
style={{ textTransform: "none" }}
>
Active
</Badge>
)}
{(
isActive(item.path as keyof typeof clientRoute) ||
(location.pathname == "/scr/dashboard/pelayanan-surat/detail-pelayanan" && item.path == "/scr/dashboard/pelayanan-surat/list-pelayanan") ||
(location.pathname == "/scr/dashboard/pengaduan/detail" && item.path == "/scr/dashboard/pengaduan/list") ||
(location.pathname == "/scr/dashboard/warga/detail-warga" && item.path == "/scr/dashboard/warga/list-warga")
)
&& (
<Badge
variant="light"
color="teal"
radius="sm"
size="xs"
style={{ textTransform: "none" }}
>
Active
</Badge>
)}
</Flex>
}
description={item.description}
@@ -313,7 +322,10 @@ function NavigationDashboard() {
navigate(clientRoutes[item.path as keyof typeof clientRoute])
}
style={{
backgroundColor: isActive(item.path as keyof typeof clientRoute)
backgroundColor: isActive(item.path as keyof typeof clientRoute) ||
(location.pathname == "/scr/dashboard/pelayanan-surat/detail-pelayanan" && item.path == "/scr/dashboard/pelayanan-surat/list-pelayanan") ||
(location.pathname == "/scr/dashboard/pengaduan/detail" && item.path == "/scr/dashboard/pengaduan/list") ||
(location.pathname == "/scr/dashboard/warga/detail-warga" && item.path == "/scr/dashboard/warga/list-warga")
? "rgba(0,255,200,0.1)"
: "transparent",
borderRadius: "8px",

View File

@@ -1,8 +1,11 @@
import BreadCrumbs from "@/components/BreadCrumbs";
import ModalFile from "@/components/ModalFile";
import ModalSurat from "@/components/ModalSurat";
import notification from "@/components/notificationGlobal";
import apiFetch from "@/lib/apiFetch";
import { parseTanggalID } from "@/server/lib/stringToDate";
import {
ActionIcon,
Anchor,
Badge,
Button,
@@ -14,24 +17,31 @@ import {
Group,
List,
Modal,
Select,
Spoiler,
Stack,
Table,
Text,
Textarea,
TextInput,
ThemeIcon,
Title,
Tooltip
} from "@mantine/core";
import { DateInput } from "@mantine/dates";
import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import {
IconAlignJustified,
IconCheck,
IconEdit,
IconFileCertificate,
IconFileCheck,
IconInfoCircle,
IconMessageReport,
IconPhone,
IconUser,
} from "@tabler/icons-react";
import dayjs from "dayjs";
import type { User } from "generated/prisma";
import type { JsonValue } from "generated/prisma/runtime/library";
import _ from "lodash";
@@ -40,6 +50,11 @@ import { useLocation } from "react-router-dom";
import useSwr from "swr";
export default function DetailPengajuanPage() {
const dataMenu = [
{ title: "Dashboard", link: "/scr/dashboard/dashboard-home", active: false },
{ title: "Pelayanan Surat", link: "/scr/dashboard/pelayanan-surat/list-pelayanan", active: false },
{ title: "Detail Pengajuan Surat", link: "#", active: true },
];
const { search } = useLocation();
const query = new URLSearchParams(search);
const id = query.get("id");
@@ -58,10 +73,14 @@ export default function DetailPengajuanPage() {
return (
<Container size="xl" py="xl" w={"100%"}>
<Grid>
<Grid.Col span={12}>
<BreadCrumbs dataLink={dataMenu} back />
</Grid.Col>
<Grid.Col span={8}>
<Stack gap={"xl"}>
<DetailDataPengajuan
data={data?.data?.pengajuan}
warga={data && data.data && data.data.warga ? data.data.warga : undefined}
syaratDokumen={data?.data?.syaratDokumen}
dataText={data?.data?.dataText}
onAction={() => {
@@ -81,15 +100,18 @@ export default function DetailPengajuanPage() {
function DetailDataPengajuan({
data,
warga,
syaratDokumen,
dataText,
onAction,
}: {
data: any;
warga?: { phone?: string | null } | null;
syaratDokumen: any;
dataText: any;
onAction: () => void;
}) {
const [opened, { open, close }] = useDisclosure(false);
const [catModal, setCatModal] = useState<"tolak" | "terima">("tolak");
const [keterangan, setKeterangan] = useState("");
@@ -98,7 +120,11 @@ function DetailDataPengajuan({
const [openedPreview, setOpenedPreview] = useState(false);
const [openedPreviewFile, setOpenedPreviewFile] = useState(false);
const [permissions, setPermissions] = useState<JsonValue[]>([]);
const [viewImg, setViewImg] = useState("");
const [viewImg, setViewImg] = useState({ file: "", folder: "" });
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(() => {
async function fetchHost() {
@@ -115,22 +141,78 @@ function DetailDataPengajuan({
fetchHost();
}, []);
async function sendWA({ status, linkSurat, linkUpdate }: { status: string, linkSurat: string, linkUpdate: string }) {
try {
const resWA = await apiFetch.api["send-wa"]["pengajuan-surat"].post({
noPengajuan: data?.noPengajuan ?? "",
jenisSurat: data?.category ?? "",
alasan: keterangan,
status,
linkSurat,
linkUpdate,
tlp: warga?.phone ?? "",
})
if (resWA?.status === 200) {
if (resWA.data?.success) {
notification({
title: "Success",
message: "Success send message to warga",
type: "success",
});
} else {
notification({
title: "Failed",
message: "Failed send message to warga",
type: "error",
});
}
} else {
notification({
title: "Failed",
message: "Failed send message to warga",
type: "error",
});
}
} catch (error) {
notification({
title: "Failed",
message: "Failed send message to warga",
type: "error",
});
}
}
const handleKonfirmasi = async (cat: "terima" | "tolak") => {
try {
const statusFix = cat == "tolak"
? "ditolak"
: data.status == "antrian"
? "diterima"
: "selesai"
const res = await apiFetch.api.pelayanan["update-status"].post({
id: data?.id,
status:
cat == "tolak"
? "ditolak"
: data.status == "antrian"
? "diterima"
: "selesai",
status: statusFix,
keterangan: keterangan,
idUser: host?.id ?? "",
noSurat: noSurat,
});
if (res?.status === 200) {
if (statusFix == "selesai") {
setTimeout(() => {
setOpenedPreview(true)
}, 1000)
} else {
sendWA({
status: statusFix,
linkSurat: "",
linkUpdate: statusFix == "ditolak" ? res.data?.linkUpdate ?? '' : '',
});
}
onAction();
close();
notification({
@@ -155,21 +237,148 @@ 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(() => {
console.log('jalan', viewImg)
if (viewImg) {
setOpenedPreviewFile(true);
}
}, [viewImg]);
useShallowEffect(() => {
if (uploading.ok && uploading.file) {
sendWA({
status: "selesai",
linkSurat: uploading.file,
linkUpdate: "",
});
}
}, [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 (
<>
{/* 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
open={openedPreviewFile && !_.isEmpty(viewImg)}
open={openedPreviewFile && !_.isEmpty(viewImg.file)}
onClose={() => {
setOpenedPreviewFile(false);
setViewImg({ file: "", folder: "" })
}}
folder="syarat-dokumen"
fileName={viewImg}
folder={viewImg.folder}
fileName={viewImg.file}
/>
{/* MODAL KONFIRMASI */}
@@ -241,10 +450,13 @@ function DetailDataPengajuan({
)}
</Stack>
</Modal>
{data?.status == "selesai" && (
{data?.status == "selesai" && !data?.fileSurat && (
<ModalSurat
open={openedPreview}
onClose={() => setOpenedPreview(false)}
onClose={(val) => {
setOpenedPreview(false)
setUploading({ ok: true, file: val })
}}
surat={data?.idSurat}
/>
)}
@@ -312,7 +524,7 @@ function DetailDataPengajuan({
<List.Item key={v.id}>
<Anchor
onClick={() => {
setViewImg(v.value);
setViewImg({ file: v.value, folder: "syarat-dokumen" });
}}
>
{v.jenis}
@@ -339,7 +551,25 @@ function DetailDataPengajuan({
</Table.Td>
<Table.Td>:</Table.Td>
<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.Tr>
))}
@@ -401,7 +631,7 @@ function DetailDataPengajuan({
<Group justify="center" grow>
<Button
variant="light"
onClick={() => setOpenedPreview(!openedPreview)}
onClick={() => { setViewImg({ file: data?.fileSurat, folder: "surat" }) }}
>
Surat
</Button>

View File

@@ -1,3 +1,4 @@
import BreadCrumbs from "@/components/BreadCrumbs";
import ModalFile from "@/components/ModalFile";
import notification from "@/components/notificationGlobal";
import apiFetch from "@/lib/apiFetch";
@@ -40,6 +41,11 @@ import useSwr from "swr";
export default function DetailPengaduanPage() {
const dataMenu = [
{ title: "Dashboard", link: "/scr/dashboard/dashboard-home", active: false },
{ title: "Pengaduan", link: "/scr/dashboard/pengaduan/list", active: false },
{ title: "Detail Pengaduan", link: "#", active: true },
];
const { search } = useLocation();
const query = new URLSearchParams(search);
const id = query.get("id");
@@ -58,6 +64,9 @@ export default function DetailPengaduanPage() {
return (
<Container size="xl" py="xl" w={"100%"}>
<Grid>
<Grid.Col span={12}>
<BreadCrumbs dataLink={dataMenu} back />
</Grid.Col>
<Grid.Col span={8}>
<Stack gap={"xl"}>
<DetailDataPengaduan
@@ -93,6 +102,7 @@ function DetailDataPengaduan({
const [keterangan, setKeterangan] = useState("");
const [host, setHost] = useState<User | null>(null);
const [permissions, setPermissions] = useState<JsonValue[]>([]);
const [isLoading, setIsLoading] = useState(false);
useEffect(() => {
async function fetchHost() {
@@ -111,6 +121,7 @@ function DetailDataPengaduan({
const handleKonfirmasi = async (cat: "terima" | "tolak") => {
try {
setIsLoading(true);
const res = await apiFetch.api.pengaduan["update-status"].post({
id: data?.id,
status:
@@ -184,6 +195,8 @@ function DetailDataPengaduan({
message: "Failed to update pengaduan",
type: "error",
});
} finally {
setIsLoading(false);
}
};
@@ -218,6 +231,7 @@ function DetailDataPengaduan({
color="red"
disabled={keterangan.length < 1}
onClick={() => handleKonfirmasi("tolak")}
loading={isLoading}
>
Tolak
</Button>
@@ -242,6 +256,7 @@ function DetailDataPengaduan({
variant="filled"
color="green"
onClick={() => handleKonfirmasi("terima")}
loading={isLoading}
>
Ya
</Button>

View File

@@ -1,3 +1,4 @@
import BreadCrumbs from "@/components/BreadCrumbs";
import DesaSetting from "@/components/DesaSetting";
import KategoriPelayananSurat from "@/components/KategoriPelayananSurat";
import KategoriPengaduan from "@/components/KategoriPengaduan";
@@ -15,14 +16,21 @@ import {
IconUsersGroup,
} from "@tabler/icons-react";
import type { JsonValue } from "generated/prisma/runtime/library";
import _ from "lodash";
import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
import { useLocation, useNavigate } from "react-router-dom";
export default function DetailSettingPage() {
const { search } = useLocation();
const query = new URLSearchParams(search);
const type = query.get("type");
const navigate = useNavigate();
const [permissions, setPermissions] = useState<JsonValue[]>([]);
const dataMenu = [
{ title: "Dashboard", link: "/scr/dashboard/dashboard-home", active: false },
{ title: "Setting", link: "#", active: false },
{ title: type == "cat-pengaduan" ? "Kategori Pengaduan" : type == "cat-pelayanan" ? "Kategori Pelayanan Surat" : type ? _.upperFirst(type) : "Profile", link: "#", active: true },
];
useEffect(() => {
async function fetchPermissions() {
@@ -87,6 +95,9 @@ export default function DetailSettingPage() {
return (
<Container size="xl" py="xl" w={"100%"}>
<Grid>
<Grid.Col span={12}>
<BreadCrumbs dataLink={dataMenu} back />
</Grid.Col>
<Grid.Col span={3}>
<Card
radius="md"
@@ -104,7 +115,7 @@ export default function DetailSettingPage() {
.map((item) => (
<NavLink
key={item.key}
href={"?type=" + item.path}
onClick={()=>{navigate("?type=" + item.path)}}
label={item.label}
leftSection={item.icon}
active={

View File

@@ -1,62 +1,75 @@
import BreadCrumbs from "@/components/BreadCrumbs";
import notification from "@/components/notificationGlobal";
import apiFetch from "@/lib/apiFetch";
import {
Avatar,
Box,
Button,
Card,
CloseButton,
Container,
Divider,
Flex,
Grid,
Group,
Input,
LoadingOverlay,
Pagination,
Stack,
Table,
Text,
Title,
} from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks";
import { IconPhone } from "@tabler/icons-react";
import { IconPhone, IconSearch } from "@tabler/icons-react";
import _ from "lodash";
import { useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import useSwr from "swr";
export default function DetailWargaPage() {
const dataMenu = [
{ title: "Dashboard", link: "/scr/dashboard/dashboard-home", active: false },
{ title: "Warga", link: "/scr/dashboard/warga/list-warga", active: false },
{ title: "Detail Warga", link: "#", active: true },
];
const { search } = useLocation();
const query = new URLSearchParams(search);
const id = query.get("id");
const { data, mutate, isLoading } = useSwr("/", () =>
apiFetch.api.warga.detail.get({
query: {
id: id!,
},
}),
);
// const { data, mutate, isLoading } = useSwr("/", () =>
// apiFetch.api.warga.detail.get({
// query: {
// id: id!,
// },
// }),
// );
useShallowEffect(() => {
mutate();
}, []);
// useShallowEffect(() => {
// mutate();
// }, []);
return (
<>
<LoadingOverlay
visible={isLoading}
// visible={isLoading}
zIndex={1000}
overlayProps={{ radius: "sm", blur: 2 }}
/>
<Container size="xl" py="xl" w={"100%"}>
<Grid>
<Grid.Col span={12}>
<BreadCrumbs dataLink={dataMenu} back />
</Grid.Col>
<Grid.Col span={4}>
<DetailWarga data={data?.data?.warga} />
<DetailWarga id={id!} />
</Grid.Col>
<Grid.Col span={8}>
<Stack gap={"xl"}>
<DetailDataHistori
data={data?.data?.pengaduan}
id={id!}
kategori="pengaduan"
/>
<DetailDataHistori
data={data?.data?.pelayanan}
id={id!}
kategori="pelayanan"
/>
</Stack>
@@ -68,13 +81,66 @@ export default function DetailWargaPage() {
}
function DetailDataHistori({
data,
id,
kategori,
}: {
data: any;
id: string;
kategori: "pengaduan" | "pelayanan";
}) {
const navigate = useNavigate();
const [data, setData] = useState<any>([]);
const [totalPages, setTotalPages] = useState(1);
const [totalRows, setTotalRows] = useState(0);
const [page, setPage] = useState(1);
const [search, setSearch] = useState("");
async function getData() {
try {
const res = await apiFetch.api.warga.detail.get({
query: {
id,
category: kategori,
page: String(page),
search
}
}) as { data: { success: boolean; data: any[]; totalPages: number, totalRows: number } };
if (res?.data?.success) {
setData(res.data.data)
setTotalPages(res?.data?.totalPages)
setTotalRows(res?.data?.totalRows)
} else {
setData([])
setTotalPages(1)
setTotalRows(0)
notification({
title: "Failed",
message: "Failed to get data",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Failed",
message: "Failed to get data",
type: "error",
});
}
}
useShallowEffect(() => {
getData()
}, [page])
useShallowEffect(() => {
setPage(1)
if (page == 1) {
getData()
}
}, [search]);
return (
<Card
@@ -93,6 +159,36 @@ function DetailDataHistori({
<Title order={4} c="gray.2">
Histori {_.upperFirst(kategori)}
</Title>
<Flex
gap="md"
justify="flex-start"
align="center"
direction="row"
>
<Input
value={search}
placeholder="Cari data..."
onChange={(event) => setSearch(event.currentTarget.value)}
leftSection={<IconSearch size={16} />}
rightSectionPointerEvents="all"
rightSection={
<CloseButton
aria-label="Clear input"
onClick={() => setSearch("")}
style={{ display: search ? undefined : "none" }}
/>
}
/>
<Text size="sm" c="gray.5" >
{`${5 * (page - 1) + 1} ${Math.min(totalRows, 5 * page)} of ${totalRows}`}
</Text>
<Pagination
total={totalPages}
value={page}
onChange={setPage}
withPages={false}
/>
</Flex>
</Flex>
<Divider my={0} />
<Table>
@@ -110,7 +206,7 @@ function DetailDataHistori({
{data?.length > 0 ? (
data?.map((item: any, index: number) => (
<Table.Tr key={index}>
<Table.Td>{item.noPengaduan}</Table.Td>
<Table.Td w={"180"}>{item.noPengaduan}</Table.Td>
<Table.Td>
{kategori == "pengaduan" ? item.title : item.category}
</Table.Td>
@@ -121,11 +217,11 @@ function DetailDataHistori({
onClick={() => {
kategori == "pengaduan"
? navigate(
`/scr/dashboard/pengaduan/detail?id=${item.id}`,
)
`/scr/dashboard/pengaduan/detail?id=${item.id}`,
)
: navigate(
`/scr/dashboard/pelayanan-surat/detail-pelayanan?id=${item.id}`,
);
`/scr/dashboard/pelayanan-surat/detail-pelayanan?id=${item.id}`,
);
}}
>
Detail
@@ -147,7 +243,33 @@ function DetailDataHistori({
);
}
function DetailWarga({ data }: { data: any }) {
function DetailWarga({ id }: { id: string }) {
const [data, setData] = useState<any>(null);
async function getWarga() {
try {
const res = await apiFetch.api.warga.detail.get({
query: {
id: id,
category: "warga",
page: "1",
search: "",
},
});
setData(res.data);
} catch (error) {
console.error(error);
notification({
title: "Failed",
message: "Failed to get data warga",
type: "error",
});
}
}
useShallowEffect(() => {
getWarga();
}, []);
return (
<Card
radius="md"

View File

@@ -8,16 +8,19 @@ export async function createSurat({ idPengajuan, idCategory, idWarga, noSurat }:
idCategory,
idWarga,
noSurat,
},
select: {
id: true
}
})
if (!surat.id) {
return { success: false, message: 'gagal membuat surat' }
return { success: false, message: 'gagal membuat surat', idSurat: '' }
}
return { success: true, message: 'surat sudah dibuat' }
return { success: true, message: 'surat sudah dibuat', idSurat: surat.id }
} catch (error) {
console.log(error)
console.error(error)
return { success: false, message: 'gagal membuat surat' }
}

View File

@@ -248,14 +248,23 @@ export async function moveFile(config: Config, oldName: string, newName: string)
return `✏️ Renamed ${oldName}${newName}`
}
export async function downloadFile(config: Config, remoteFile: string, localFile?: string): Promise<string> {
const localName = localFile || remoteFile;
const downloadUrlResponse = await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?p=/${remoteFile}`);
const downloadUrl = (await downloadUrlResponse.text()).replace(/"/g, '');
export async function downloadFile(config: Config, fileName: string, folder: string, localFile?: string): Promise<string> {
const localName = localFile || fileName;
// 🔹 gabungkan path folder + file
const filePath = `/${folder}/${fileName}`.replace(/\/+/g, "/");
// 🔹 encode path agar aman (spasi, dll)
const params = new URLSearchParams({
p: filePath,
});
const downloadUrlResponse = await fetchWithAuth(config, `${config.URL}/${config.REPO}/file/?${params.toString()}`);
if(!downloadUrlResponse.ok)
return 'gagal'
const downloadUrl = (await downloadUrlResponse.text()).replace(/"/g, '');
const buffer = Buffer.from(await (await fetchWithAuth(config, downloadUrl)).arrayBuffer());
await fs.writeFile(localName, buffer);
return `⬇️ Downloaded ${remoteFile}${localName}`
return `⬇️ Downloaded ${fileName}${localName}`
}
export async function getFileLink(config: Config, fileName: string): Promise<string> {

View File

@@ -265,6 +265,7 @@ const PelayananRoute = new Elysia({
select: {
id: true,
idCategory: true,
file: true
}
})
@@ -307,7 +308,11 @@ const PelayananRoute = new Elysia({
})
const dataTextCategory = (data?.CategoryPelayanan?.dataPelengkap ?? []) as {
name: string;
type: string;
options?: {
label: string,
value: string
}[]; name: string;
desc: string;
key: string;
}[];
@@ -326,7 +331,10 @@ const PelayananRoute = new Elysia({
return {
id: item.id,
jenis: nama,
key: ref?.key,
value: item.value,
type: ref?.type ?? "",
options: ref?.options ?? [],
order: ref?.order ?? Infinity,
};
})
@@ -381,6 +389,7 @@ const PelayananRoute = new Elysia({
createdAt: data?.createdAt,
updatedAt: data?.updatedAt,
idSurat: dataSurat?.id,
fileSurat: dataSurat?.file,
}
const datafix = {
@@ -834,9 +843,14 @@ const PelayananRoute = new Elysia({
})
if (!pengajuan) {
return { success: false, message: 'gagal update status pengajuan surat' }
return { success: false, message: 'gagal update status pengajuan surat', linkUpdate: '', idSurat: '' }
}
const dataPengajuan = await prisma.pelayananAjuan.findUnique({
where: { id: pengajuan.id },
select: { noPengajuan: true }
});
if (status === "diterima") {
deskripsi = "Pengajuan surat diterima"
} else if (status === "ditolak") {
@@ -855,11 +869,19 @@ const PelayananRoute = new Elysia({
}
})
let idSurat = "";
if (status === "selesai") {
await createSurat({ idPengajuan: pengajuan.id, idCategory: pengajuan.idCategory, idWarga: pengajuan.idWarga, noSurat })
const result = await createSurat({ idPengajuan: pengajuan.id, idCategory: pengajuan.idCategory, idWarga: pengajuan.idWarga, noSurat })
idSurat = result.idSurat ?? "";
}
return { success: true, message: 'pengajuan surat sudah diperbarui' }
return {
success: true,
message: 'pengajuan surat sudah diperbarui',
linkUpdate: status == "ditolak" ? `${process.env.BUN_PUBLIC_BASE_URL}/darmasaba/update-data-surat?pengajuan=${dataPengajuan?.noPengajuan}` : '',
idSurat: idSurat,
}
}, {
body: t.Object({
id: t.String({ minLength: 1, error: "id harus diisi" }),
@@ -1035,6 +1057,73 @@ const PelayananRoute = new Elysia({
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 }) => {
const { take, page, search, status } = query
const skip = !page ? 0 : (Number(page) - 1) * (!take ? 10 : Number(take))

View File

@@ -1,14 +1,16 @@
import Elysia, { t } from "elysia"
import type { StatusPengaduan } from "generated/prisma"
import _ from "lodash"
import { v4 as uuidv4 } from "uuid"
import { getLastUpdated } from "../lib/get-last-updated"
import { mimeToExtension } from "../lib/mimetypeToExtension"
import { generateNoPengaduan } from "../lib/no-pengaduan"
import { isValidPhone, normalizePhoneNumber } from "../lib/normalizePhone"
import { prisma } from "../lib/prisma"
import { renameFile } from "../lib/rename-file"
import { catFile, defaultConfigSF, removeFile, uploadFile, uploadFileToFolder } from "../lib/seafile"
import Elysia, { t } from "elysia";
import fs from 'fs';
import type { StatusPengaduan } from "generated/prisma";
import _ from "lodash";
import path from "path";
import { v4 as uuidv4 } from "uuid";
import { getLastUpdated } from "../lib/get-last-updated";
import { mimeToExtension } from "../lib/mimetypeToExtension";
import { generateNoPengaduan } from "../lib/no-pengaduan";
import { isValidPhone, normalizePhoneNumber } from "../lib/normalizePhone";
import { prisma } from "../lib/prisma";
import { renameFile } from "../lib/rename-file";
import { catFile, defaultConfigSF, downloadFile, removeFile, uploadFile, uploadFileToFolder } from "../lib/seafile";
const PengaduanRoute = new Elysia({
prefix: "pengaduan",
@@ -605,6 +607,43 @@ const PengaduanRoute = new Elysia({
consumes: ["multipart/form-data"]
},
})
.get("/download", async ({ query, set }) => {
const { file, folder } = query;
// Validasi file
if (!file) {
return { success: false, message: "File tidak ditemukan" };
}
// if (!folder) {
// return { success: false, message: "Folder tidak ditemukan" };
// }
const localPath = path.join("/tmp", file);
// Upload ke Seafile (pastikan uploadFile menerima Blob atau ArrayBuffer)
// const buffer = await file.arrayBuffer();
const result = await downloadFile(defaultConfigSF, file, 'surat', localPath);
if(result=="gagal") {
return { success: false, message: "Download gagal" };
}
set.headers["Content-Type"] = "application/pdf";
set.headers["Content-Disposition"] = `attachment; filename="${file}"`;
// 🔹 kirim file ke browser
return fs.createReadStream(localPath);
}, {
body: t.Object({
file: t.Any(),
folder: t.String(),
}),
detail: {
summary: "Download Surat",
description: "Tool untuk download surat dari Seafile",
},
})
.post("/upload-file-form-data", async ({ body }) => {
const { file } = body;

View File

@@ -76,6 +76,78 @@ Terima kasih atas partisipasi dan kepercayaan Bapak/Ibu.`
description: `tool untuk send pemberitahuan pengaduan lewat WA`
}
})
.post("/pengajuan-surat", async ({ body }) => {
const { noPengajuan, jenisSurat, status, alasan, tlp, linkSurat, linkUpdate } = body
let text = ""
if (status === "ditolak") {
text = `Pemberitahuan Pengajuan Surat
Nomor Pengajuan: ${noPengajuan}
Surat: ${jenisSurat}
Kami informasikan bahwa pengajuan surat tersebut tidak dapat diproses (ditolak).
Alasan penolakan: ${alasan}
Bapak/Ibu dapat melakukan perbaikan atau pembaruan data melalui tautan berikut:
👉 ${linkUpdate}
Setelah data diperbarui, pengajuan akan diproses kembali sesuai ketentuan yang berlaku.
Terima kasih atas pengertian Bapak/Ibu.`
} else if (status == "diterima") {
text = `Pemberitahuan Pengajuan Surat
Nomor Pengajuan: ${noPengajuan}
Surat: ${jenisSurat}
Kami informasikan bahwa pengajuan surat yang Bapak/Ibu ajukan telah kami terima dan sedang menunggu proses verifikasi serta penanganan lebih lanjut.
Terima kasih atas kesabaran Bapak/Ibu.`
} else if (status == "selesai") {
text = `Pemberitahuan Pengajuan Surat
Nomor Pengajuan: ${noPengajuan}
Surat: ${jenisSurat}
Kami informasikan bahwa pengajuan surat tersebut telah selesai diproses.
Bapak/Ibu dapat mengunduh surat melalui tautan berikut:
👉 ${linkSurat}
Terima kasih atas kepercayaan Bapak/Ibu.`
}
const textFix = encodeURIComponent(text)
const res = await fetch(
`https://cld-dkr-prod-wajs-server.wibudev.com/api/wa/code?nom=${tlp}&text=${textFix}`,
{
cache: "no-cache",
headers: {
Authorization: `Bearer ${process.env.WA_SERVER_TOKEN}`,
},
}
);
if (res.status !== 200)
return { success: false, message: "Nomor Whatsapp Tidak Aktif" }
return { success: true, message: 'Pemberitahuan berhasil dikirim ke warga' }
}, {
body: t.Object({
noPengajuan: t.String({ minLength: 1, error: "nomer pengajuan harus diisi" }),
jenisSurat: t.String({ minLength: 1, error: "jenis surat harus diisi" }),
status: t.String({ minLength: 1, error: "status harus diisi" }),
alasan: t.String({ optional: true }),
linkSurat: t.String({ optional: true }),
linkUpdate: t.String({ optional: true }),
tlp: t.String({ minLength: 1, error: "nomor telepon harus diisi" }),
}),
detail: {
summary: "Send pemberitahuan pengajuan surat lewat WA",
description: `tool untuk send pemberitahuan pengajuan surat lewat WA`
}
})
;
export default SendWaRoute

View File

@@ -17,6 +17,7 @@ const SuratRoute = new Elysia({
noSurat: true,
idCategory: true,
createdAt: true,
file: true,
PelayananAjuan: {
select: {
DataTextPelayanan: true,
@@ -44,6 +45,7 @@ const SuratRoute = new Elysia({
idCategory: dataSurat?.idCategory,
nameCategory: dataSurat?.CategoryPelayanan?.name,
noSurat: dataSurat?.noSurat,
file: dataSurat?.file,
dataText: dataSurat?.PelayananAjuan?.DataTextPelayanan,
createdAt: dataSurat?.createdAt.toLocaleDateString("id-ID", { day: "numeric", month: "long", year: "numeric" }),
},
@@ -60,6 +62,33 @@ const SuratRoute = new Elysia({
}
})
.post("/update", async ({ body }) => {
const { id, filename } = body
await prisma.suratPelayanan.update({
where: {
id,
},
data: {
file: filename,
}
})
return {
success: true,
message: 'surat sudah diperbarui',
link: `${process.env.BUN_PUBLIC_BASE_URL}/api/pengaduan/download?file=${filename}`
}
}, {
body: t.Object({
id: t.String({ minLength: 1, error: "id harus diisi" }),
filename: t.String({ minLength: 1, error: "filename harus diisi" }),
}),
detail: {
summary: "update file surat",
description: `tool untuk update file surat`
}
})
;
export default SuratRoute

View File

@@ -97,68 +97,137 @@ const WargaRoute = new Elysia({
}
})
.get("/detail", async ({ query }) => {
const { id } = query
const { id, category, search, page } = query
const skip = !page ? 0 : (Number(page) - 1) * 5
const dataWarga = await prisma.warga.findUnique({
where: {
id
}
})
const dataPengaduan = await prisma.pengaduan.findMany({
orderBy: {
createdAt: "desc"
},
where: {
if (!dataWarga)
return { success: false, message: "data warga tidak ditemukan", data: null, totalPages: 1, totalRows: 0 }
if (category == "warga") {
return dataWarga
} else if (category == "pengaduan") {
const where: any = {
isActive: true,
idWarga: id
},
select: {
id: true,
status: true,
noPengaduan: true,
title: true
idWarga: id,
OR: [
{
title: {
contains: search ?? "",
mode: "insensitive"
},
},
{
noPengaduan: {
contains: search ?? "",
mode: "insensitive"
},
}
]
}
})
const totalData = await prisma.pengaduan.count({
where
});
const dataPengaduan = await prisma.pengaduan.findMany({
skip,
take: 5,
orderBy: {
createdAt: "desc"
},
where,
select: {
id: true,
status: true,
noPengaduan: true,
title: true
}
})
const dataReturn = {
success: true,
message: "data pengaduan berhasil diambil",
data: dataPengaduan,
totalRows: totalData,
totalPages: Math.ceil(totalData / 5)
}
const dataPelayanan = await prisma.pelayananAjuan.findMany({
orderBy: {
createdAt: "desc"
},
where: {
return dataReturn
} else if (category == "pelayanan") {
const where: any = {
isActive: true,
idWarga: id
},
select: {
id: true,
noPengajuan: true,
status: true,
CategoryPelayanan: {
select: {
name: true
idWarga: id,
OR: [
{
CategoryPelayanan: {
name: {
contains: search ?? "",
mode: "insensitive"
},
},
},
{
noPengajuan: {
contains: search ?? "",
mode: "insensitive"
},
},
]
}
const totalData = await prisma.pelayananAjuan.count({
where
});
const dataPelayanan = await prisma.pelayananAjuan.findMany({
skip,
take: 5,
orderBy: {
createdAt: "desc"
},
where,
select: {
id: true,
noPengajuan: true,
status: true,
CategoryPelayanan: {
select: {
name: true
}
}
}
})
const dataPelayanFix = dataPelayanan.map((v: any) => ({
..._.omit(v, ["CategoryPelayanan"]),
id: v.id,
noPengaduan: v.noPengajuan,
status: v.status,
category: v.CategoryPelayanan.name
}))
const dataReturn = {
success: true,
message: "data pelayanan berhasil diambil",
data: dataPelayanFix,
totalRows: totalData,
totalPages: Math.ceil(totalData / 5)
}
})
const dataPelayanFix = dataPelayanan.map((v: any) => ({
..._.omit(v, ["CategoryPelayanan"]),
id: v.id,
noPengaduan: v.noPengajuan,
status: v.status,
category: v.CategoryPelayanan.name
}))
return {
warga: dataWarga,
pengaduan: dataPengaduan,
pelayanan: dataPelayanFix
return dataReturn
}
}, {
query: t.Object({
id: t.String({ minLength: 1, error: "id harus diisi" })
id: t.String({ minLength: 1, error: "id harus diisi" }),
category: t.String({ minLength: 1, error: "kategori harus diisi" }),
page: t.String({ optional: true }),
search: t.String({ optional: true }),
}),
detail: {
summary: "Detail Warga",