Compare commits

..

13 Commits

Author SHA1 Message Date
fa201274d4 fix:list user 2025-11-28 17:38:52 +08:00
90b8fdf573 fixing : list user 2025-11-28 17:32:23 +08:00
239b1bdc1b Merge pull request 'fix: list user' (#44) from amalia/28-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/44
2025-11-28 17:22:02 +08:00
f99da3b2a6 fix: list user 2025-11-28 17:21:14 +08:00
3866f71e2d Merge pull request 'upd' (#43) from amalia/28-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/43
2025-11-28 17:12:38 +08:00
d57ff92aa9 upd
fix: list user
2025-11-28 17:11:45 +08:00
f901cff3b1 Merge pull request 'amalia/28-nov-25' (#42) from amalia/28-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/42
2025-11-28 17:02:35 +08:00
fe3ebf4bd3 upd: dashboard admin 2025-11-28 17:01:19 +08:00
075cb12417 upd: dashboard admin
Deskripsi:
- view file pada detail pengaduan
- view file pada detail pelayanan surat
- close modal file jika error

No Issues
2025-11-28 16:04:07 +08:00
cd7e602254 upd: menu home
Deskripsi
:
- tampilan grafik

NO Issues
2025-11-28 10:57:58 +08:00
f9b84f89eb Merge pull request 'update: dashboard admin' (#41) from amalia/27-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/41
2025-11-27 18:03:22 +08:00
cca1840922 update: dashboard admin
Deskripsi:
- menu dashboard
- api dashboard

No Issues
2025-11-27 17:55:01 +08:00
c622565bb7 Merge pull request 'upd: api detail pengaduan jenna ai' (#40) from amalia/26-nov-25 into main
Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/40
2025-11-26 14:30:48 +08:00
15 changed files with 714 additions and 178 deletions

View File

@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "jenna-mcp",
@@ -21,6 +22,8 @@
"@types/lodash": "^4.17.20",
"@types/uuid": "^11.0.0",
"add": "^2.0.6",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.5",
"elysia": "^1.4.15",
"html2canvas": "^1.4.1",
"jspdf": "^3.0.3",
@@ -289,6 +292,10 @@
"ecc-jsbn": ["ecc-jsbn@0.1.2", "", { "dependencies": { "jsbn": "~0.1.0", "safer-buffer": "^2.1.0" } }, "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw=="],
"echarts": ["echarts@6.0.0", "", { "dependencies": { "tslib": "2.3.0", "zrender": "6.0.0" } }, "sha512-Tte/grDQRiETQP4xz3iZWSvoHrkCQtwqd6hs+mifXcjrCuo2iKWbajFObuLJVBlDIJlOzgQPd1hsaKt/3+OMkQ=="],
"echarts-for-react": ["echarts-for-react@3.0.5", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "size-sensor": "^1.0.1" }, "peerDependencies": { "echarts": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0", "react": "^15.0.0 || >=16.0.0" } }, "sha512-YpEI5Ty7O/2nvCfQ7ybNa+S90DwE8KYZWacGvJW4luUqywP7qStQ+pxDlYOmr4jGDu10mhEkiAuMKcUlT4W5vg=="],
"editor": ["editor@1.0.0", "", {}, "sha512-SoRmbGStwNYHgKfjOrX2L0mUvp9bUVv0uPppZSOMAntEbcFtoC3MKF5b3T6HQPXKIV+QGY3xPO3JK5it5lVkuw=="],
"ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="],
@@ -645,6 +652,8 @@
"side-channel-weakmap": ["side-channel-weakmap@1.0.2", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3", "side-channel-map": "^1.0.1" } }, "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A=="],
"size-sensor": ["size-sensor@1.0.2", "", {}, "sha512-2NCmWxY7A9pYKGXNBfteo4hy14gWu47rg5692peVMst6lQLPKrVjhY+UTEsPI5ceFRJSl3gVgMYaUi/hKuaiKw=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"sshpk": ["sshpk@1.18.0", "", { "dependencies": { "asn1": "~0.2.3", "assert-plus": "^1.0.0", "bcrypt-pbkdf": "^1.0.0", "dashdash": "^1.12.0", "ecc-jsbn": "~0.1.1", "getpass": "^0.1.1", "jsbn": "~0.1.0", "safer-buffer": "^2.0.2", "tweetnacl": "~0.14.0" }, "bin": { "sshpk-conv": "bin/sshpk-conv", "sshpk-sign": "bin/sshpk-sign", "sshpk-verify": "bin/sshpk-verify" } }, "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ=="],
@@ -687,7 +696,7 @@
"tough-cookie": ["tough-cookie@2.5.0", "", { "dependencies": { "psl": "^1.1.28", "punycode": "^2.1.1" } }, "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="],
"tunnel-agent": ["tunnel-agent@0.6.0", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w=="],
@@ -743,6 +752,8 @@
"zod-to-json-schema": ["zod-to-json-schema@3.24.6", "", { "peerDependencies": { "zod": "^3.24.1" } }, "sha512-h/z3PKvcTcTetyjl1fkj79MHNEjm+HpD6NXheWjzOekY7kV+lwDYnHw+ivHkijnCSMz1yJaWBD9vu/Fcmk+vEg=="],
"zrender": ["zrender@6.0.0", "", { "dependencies": { "tslib": "2.3.0" } }, "sha512-41dFXEEXuJpNecuUQq6JlbybmnHaqqpGlbH1yxnA5V9MMP4SbohSVZsJIwz+zdjQXSSlR1Vc34EgH1zxyTDvhg=="],
"@scalar/themes/@scalar/types": ["@scalar/types@0.1.7", "", { "dependencies": { "@scalar/openapi-types": "0.2.0", "@unhead/schema": "^1.11.11", "nanoid": "^5.1.5", "type-fest": "^4.20.0", "zod": "^3.23.8" } }, "sha512-irIDYzTQG2KLvFbuTI8k2Pz/R4JR+zUUSykVTbEMatkzMmVFnn1VzNSMlODbadycwZunbnL2tA27AXed9URVjw=="],
"body-parser/iconv-lite": ["iconv-lite@0.6.3", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw=="],
@@ -769,12 +780,22 @@
"pkg-types/pathe": ["pathe@2.0.3", "", {}, "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w=="],
"react-remove-scroll/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"react-remove-scroll-bar/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"react-style-singleton/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"request/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
"request/qs": ["qs@6.5.3", "", {}, "sha512-qxXIEh4pCGfHICj1mAJQ2/2XVZkjCDTcEgfoSQxc/fYivUZxTkk7L3bDBJSoNrEzXI17oUO5Dp07ktqE5KzczA=="],
"request/uuid": ["uuid@3.4.0", "", { "bin": { "uuid": "./bin/uuid" } }, "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A=="],
"use-callback-ref/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"use-sidecar/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"@scalar/themes/@scalar/types/@scalar/openapi-types": ["@scalar/openapi-types@0.2.0", "", { "dependencies": { "zod": "^3.23.8" } }, "sha512-waiKk12cRCqyUCWTOX0K1WEVX46+hVUK+zRPzAahDJ7G0TApvbNkuy5wx7aoUyEk++HHde0XuQnshXnt8jsddA=="],
"@scalar/themes/@scalar/types/nanoid": ["nanoid@5.1.6", "", { "bin": { "nanoid": "bin/nanoid.js" } }, "sha512-c7+7RQ+dMB5dPwwCp4ee1/iV/q2P6aK1mTZcfr1BTuVlyW9hJYiMPybJCcnBlQtuSmTIWNeazm/zqNoZSSElBg=="],

View File

@@ -28,6 +28,8 @@
"@types/lodash": "^4.17.20",
"@types/uuid": "^11.0.0",
"add": "^2.0.6",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.5",
"elysia": "^1.4.15",
"html2canvas": "^1.4.1",
"jspdf": "^3.0.3",

View File

@@ -0,0 +1,98 @@
import apiFetch from "@/lib/apiFetch";
import { Card, Flex, Grid, Group, Stack, Text } from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks";
import { IconFileCertificate, IconMessageReport, IconUsers } from "@tabler/icons-react";
import useSWR from "swr";
export default function DashboardCountData() {
const { data, mutate, isLoading } = useSWR("/", () =>
apiFetch.api.dashboard.count.get()
);
useShallowEffect(() => {
mutate();
}, []);
return (
<Grid>
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
<MetricCard
icon={<IconMessageReport size={28} />}
label="Pengaduan Hari Ini"
value={String(data?.data?.pengaduan?.today)}
change={String(data?.data?.pengaduan?.kenaikan) + "%"}
color={(data?.data?.pengaduan?.kenaikan || 0) > 0 ? "teal" : "gray"}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
<MetricCard
icon={<IconFileCertificate size={28} />}
label="Pengajuan Surat Hari Ini"
value={String(data?.data?.pelayanan?.today)}
change={String(data?.data?.pelayanan?.kenaikan) + "%"}
color={(data?.data?.pelayanan?.kenaikan || 0) > 0 ? "teal" : "gray"}
/>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 4 }}>
<MetricCard
icon={<IconUsers size={28} />}
label="Warga"
value={String(data?.data?.warga)}
color="blue"
/>
</Grid.Col>
</Grid>
);
}
function MetricCard({
icon,
label,
value,
change,
color,
}: {
icon: React.ReactNode;
label: string;
value: string;
change?: string;
color: string;
}) {
return (
<Card
radius="lg"
p="md"
withBorder
style={{
background:
"linear-gradient(145deg, rgba(30,30,30,0.95), rgba(55,55,55,0.9))",
borderColor: "rgba(100,100,100,0.2)",
transition: "transform 0.15s ease, box-shadow 0.15s ease",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.boxShadow = "0 0 10px rgba(0,255,200,0.2)")
}
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = "none")}
>
<Stack gap={6}>
<Group gap={6}>
{icon}
<Text size="sm" c="dimmed">
{label}
</Text>
</Group>
<Flex align="center" justify="space-between">
<Text fw={600} size="xl" c="gray.0">
{value}
</Text>
{change && (
<Text size="sm" c={color}>
{change}
</Text>
)}
</Flex>
</Stack>
</Card>
);
}

View File

@@ -0,0 +1,83 @@
import apiFetch from "@/lib/apiFetch";
import { Card, Divider, Flex, Stack, Text, Title } from "@mantine/core";
import { IconChartBar } from "@tabler/icons-react";
import type { EChartsOption } from "echarts";
import EChartsReact from "echarts-for-react";
import { useEffect, useState } from "react";
import useSWR from "swr";
export default function DashboardGrafik() {
const [options, setOptions] = useState<EChartsOption>({});
const { data, mutate, isLoading } = useSWR(
"grafik-dashboard",
async () => {
return apiFetch.api.dashboard.grafik.get().then(res => res.data);
}
);
const loadData = () => {
if (!data) return;
const option: EChartsOption = {
darkMode: true,
animation: true,
legend: {
textStyle: { color: "#fff" }
},
tooltip: {},
dataset: {
dimensions: data.dimensions,
source: data.source
},
xAxis: {
type: "category",
axisLabel: { color: "#fff" }
},
yAxis: {
type: "value",
minInterval: 1,
axisLabel: { color: "#fff" }
},
color: ["#1abc9c", "#10816aff"],
series: [
{ type: "bar" },
{ type: "bar" }
]
};
setOptions(option);
};
useEffect(() => {
if (data) loadData();
}, [data]);
return (
<Card
radius="lg"
p="xl"
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 25px rgba(0,255,200,0.08)",
}}
>
<Stack gap="sm">
<Flex align="center" justify="space-between">
<Flex direction={"column"}>
<Title order={4} c="gray.0">
Grafik Pengaduan dan Pelayanan Surat
</Title>
<Text size="sm">7 Hari Terakhir</Text>
</Flex>
<IconChartBar size={20} color="gray" />
</Flex>
<Divider my="xs" />
<Stack gap="sm">
<EChartsReact style={{ height: 400 }} option={options} />
</Stack>
</Stack>
</Card>
)
}

View File

@@ -0,0 +1,133 @@
import apiFetch from "@/lib/apiFetch";
import { Badge, Button, Card, Flex, Group, Stack, Text, Title, Tooltip } from "@mantine/core";
import { useShallowEffect } from "@mantine/hooks";
import { useNavigate } from "react-router-dom";
import useSWR from "swr";
export default function DashboardLastData() {
const navigate = useNavigate();
const { data, mutate, isLoading } = useSWR("last-update", async () => {
const res = await apiFetch.api.dashboard["last-update"].get();
return res.data
});
useShallowEffect(() => {
mutate();
}, []);
return (
<Flex justify="flex-start" gap="md">
<Card
radius="lg"
p="xl"
withBorder
w={"50%"}
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 25px rgba(0,255,200,0.08)",
}}
>
<Stack gap="sm">
<Flex align="center" pb={"sm"} justify="space-between" style={{ borderBottom: "1px solid rgba(255,255,255,0.1)" }}>
<Title order={4} c="gray.0">
Last update pengaduan
</Title>
<Button variant="subtle" size="xs" radius="md" onClick={() => navigate(`/scr/dashboard/pengaduan/list`)}>View All</Button>
</Flex>
<Stack gap="sm" mt="md" align="stretch" justify="center">
{
data && Array.isArray(data.pengaduan) && data.pengaduan.length > 0 ? data.pengaduan.map((item: any, index: number) => (
<PengaduanSection
key={index}
id={item.id}
nomer={item.noPengaduan}
judul={item.title}
status={item.status}
updated={item.updatedAt}
kategori="pengaduan"
/>
)) : <Text c="dimmed" ta={"center"} >Tidak ada data</Text>
}
</Stack>
</Stack>
</Card>
<Card
radius="lg"
p="xl"
withBorder
w={"50%"}
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 25px rgba(0,255,200,0.08)",
}}
>
<Stack gap="sm">
<Flex align="center" pb={"sm"} justify="space-between" style={{ borderBottom: "1px solid rgba(255,255,255,0.1)" }}>
<Title order={4} c="gray.0">
Last update pelayanan surat
</Title>
<Button variant="subtle" size="xs" radius="md" onClick={() => navigate(`/scr/dashboard/pelayanan-surat/list-pelayanan`)}>View All</Button>
</Flex>
<Stack gap="sm" mt="md" align="stretch" justify="center">
{
data && Array.isArray(data.pelayanan) && data.pelayanan.length > 0 ? data.pelayanan.map((item: any, index: number) => (
<PengaduanSection
key={index}
id={item.id}
nomer={item.noPengajuan}
judul={item.title}
status={item.status}
updated={item.updatedAt}
kategori="pelayanan"
/>
)) : <Text c="dimmed" ta={"center"} >Tidak ada data</Text>
}
</Stack>
</Stack>
</Card>
</Flex>
);
}
function PengaduanSection({ id, nomer, judul, status, updated, kategori }: { id: string, nomer: string, judul: string, status: string, updated: string, kategori: 'pengaduan' | 'pelayanan' }) {
const navigate = useNavigate();
return (
<Stack
gap="xs"
onClick={() => navigate(kategori == "pelayanan" ? `/scr/dashboard/pelayanan-surat/detail-pelayanan?id=${id}` : `/scr/dashboard/pengaduan/detail?id=${id}`)}
>
<Flex align="center" pb={"sm"} justify="space-between" gap="md" style={{ borderBottom: "1px solid rgba(255,255,255,0.1)" }}>
<Flex direction={"column"}>
<Text size="md" c="gray.2" lineClamp={1}>
{judul}
</Text>
<Group>
<Text size="sm" c="dimmed">
#{nomer} {updated}
</Text>
</Group>
</Flex>
<Tooltip label={status}>
<Badge size="xs" circle color={
status === "diterima"
? "green"
: status === "ditolak"
? "red"
: status === "selesai"
? "blue"
: status === "dikerjakan"
? "gray"
: "yellow"
} />
</Tooltip>
</Flex>
</Stack>
)
}

View File

@@ -7,6 +7,7 @@ export default function ModalFile({ open, onClose, folder, fileName }: { open: b
const [viewFile, setViewFile] = useState<string>("");
const [loading, setLoading] = useState<boolean>(false);
const [typeFile, setTypeFile] = useState<string>("");
const [error, setError] = useState<boolean>(false);
useEffect(() => {
if (open && fileName) {
@@ -27,23 +28,36 @@ export default function ModalFile({ open, onClose, folder, fileName }: { open: b
// load file
const urlApi = '/api/pengaduan/image?folder=' + folder + '&fileName=' + fileName;
const res = await fetch(urlApi);
if (!res.ok)
if (!res.ok) {
setError(true);
return notification({
title: "Error",
message: "Failed to load image",
type: "error",
});
}
const blob = await res.blob();
const url = URL.createObjectURL(blob);
setViewFile(url);
} catch (err) {
console.error("Gagal load gambar:", err);
setError(true);
notification({
title: "Error",
message: "Failed to load image",
type: "error",
});
} finally {
setLoading(false);
}
};
useEffect(() => {
if (error) {
onClose();
}
}, [error]);
return (

View File

@@ -465,13 +465,13 @@ export default function UserSetting({ permissions }: { permissions: JsonValue[]
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{list.length > 0 ? (
{list && Array.isArray(list) && list.length > 0 ? (
list?.map((v: any) => (
<Table.Tr key={v.id}>
<Table.Td>{v.name}</Table.Td>
<Table.Td>{v.phone}</Table.Td>
<Table.Td>{v.email}</Table.Td>
<Table.Td>{v.roleId}</Table.Td>
<Table.Td>{v.nameRole}</Table.Td>
<Table.Td>
<Group>
<Tooltip label={permissions.includes('setting.user.edit') ? "Edit User" : "Edit User - Anda tidak memiliki akses"}>

View File

@@ -10,14 +10,15 @@ import Auth from "./server/routes/auth_route";
import ConfigurationDesaRoute from "./server/routes/configuration_desa_route";
import CredentialRoute from "./server/routes/credential_route";
import DarmasabaRoute from "./server/routes/darmasaba_route";
import DashboardRoute from "./server/routes/dashboard_route";
import LayananRoute from "./server/routes/layanan_route";
import { MCPRoute } from "./server/routes/mcp_route";
import PelayananRoute from "./server/routes/pelayanan_surat_route";
import PengaduanRoute from "./server/routes/pengaduan_route";
import SuratRoute from "./server/routes/surat_route";
import TestPengaduanRoute from "./server/routes/test_pengaduan";
import UserRoute from "./server/routes/user_route";
import WargaRoute from "./server/routes/warga_route";
import SuratRoute from "./server/routes/surat_route";
const Docs = new Elysia({
tags: ["docs"],
@@ -31,6 +32,7 @@ const Api = new Elysia({
prefix: "/api",
tags: ["api"],
})
.use(DashboardRoute)
.use(PengaduanRoute)
.use(PelayananRoute)
.use(ConfigurationDesaRoute)

View File

@@ -17,7 +17,7 @@ export const categoryPelayananSurat = [
{ name: "ktp/kk", desc: "Fotokopi KTP atau Kartu Keluarga" },
{ name: "akta cerai", desc: "Fotokopi Akta Cerai bagi yang berstatus janda/duda" }
],
dataText: ["nik", "nama", "tempat tanggal lahir", "jenis kelamin", "alamat", "status perkawinan"]
dataText: ["nik", "nama", "tempat tanggal lahir", "jenis kelamin", "alamat", "agama", "pekerjaan"]
},
{
id: "skdomisiliorganisasi",
@@ -27,7 +27,7 @@ export const categoryPelayananSurat = [
{ name: "skt organisasi", desc: "Fotokopi Surat Keterangan Terdaftar (SKT) Organisasi atau Pengukuhan Kelompok" },
{ name: "susunan pengurus", desc: "Jika Pengajuan baru pembuatan SKT maka melengkapi Susunan Pengurus lengkap denganKop Organisasi" }
],
dataText: ["nama organisasi", "alamat organisasi", "nama pemohon", "jabatan pemohon", "kontak", "penanggung jawab", "tanggal berdiri"]
dataText: ["nama organisasi", "jenis organisasi", "alamat organisasi/sekretariat", "no telepon", "nama pimpinan", "keperluan"]
},
{
id: "skkelahiran",
@@ -45,7 +45,7 @@ export const categoryPelayananSurat = [
{ name: "pengantar kelian", desc: "Surat Pengantar Kelian Banjar Dinas" },
{ name: "ktp/kk", desc: "Fotokopi KTP atau Kartu Keluarga" }
],
dataText: ["nik", "nama", "tempat tanggal lahir", "jenis kelamin", "alamat", "polsek"]
dataText: ["nik", "nama", "tempat tanggal lahir", "jenis kelamin", "agama", "alamat", "pekerjaan", "polsek"]
},
{
id: "skkematian",
@@ -55,7 +55,7 @@ export const categoryPelayananSurat = [
{ name: "ktp/kk", desc: "Fotokopi KTP atau Kartu Keluarga" },
{ name: "surat kematian", desc: "Surat Keterangan Kematian dari Rumah Sakit/Dokter (jika ada)" }
],
dataText: ["nama almarhum", "nik", "tempat tanggal lahir", "alamat", "tanggal kematian", "waktu kematian", "penyebab kematian"]
dataText: ["nik pelapor", "nama pelapor", "pekerjaan pelapor", "alamat pelapor", "hubungan pelapor dengan almarhum", "nama almarhum", "nik almarhum", "tempat tanggal lahir almarhum", "alamat almarhum", "agama almarhum", "tanggal kematian", "waktu kematian", "tempat kematian", "penyebab kematian"]
},
{
id: "skpenghasilan",
@@ -65,7 +65,7 @@ export const categoryPelayananSurat = [
{ name: "ktp ortu/kk", desc: "Fotokopi KTP orang tua atau Kartu Keluarga" },
{ name: "surat pernyataan", desc: "Surat Pernyataan Penghasilan bermaterai" }
],
dataText: ["nama", "nik", "alamat", "pekerjaan", "jenis usaha", "penghasilan", "alasan permohonan"]
dataText: ["nama", "tempat tanggal lahir", "jenis kelamin", "alamat", "pekerjaan", "penghasilan", "alasan permohonan"]
},
{
id: "sktempatusaha",
@@ -76,7 +76,7 @@ export const categoryPelayananSurat = [
{ name: "foto lokasi", desc: "Foto lokasi usaha dicetak dalam selembar kertas, diparaf dan distempel oleh Kelian" },
{ name: "sppt/sertifikat/sewa", desc: "Fotokopi SPPT, Sertifikat Hak Milik, Surat Perjanjian Sewa, atau Kwitansi Pembayaran Sewa 3 bulan terakhir" }
],
dataText: ["nama usaha", "bidang usaha", "alamat usaha", "status tempat usaha", "luas tempat usaha", "jumlah karyawan", "tujuan pembuatan surat"]
dataText: ["nik", "nama pemilik", "tempat tanggal lahir", "alamat pemilik", "nama usaha", "bidang usaha", "alamat usaha", "status tempat usaha", "luas tempat usaha", "jumlah karyawan", "tujuan pembuatan surat"]
},
{
id: "sktidakmampu",
@@ -104,6 +104,6 @@ export const categoryPelayananSurat = [
{ name: "pengantar kelian", desc: "Surat Pengantar Kelian Banjar Dinas" },
{ name: "ktp/kia/kk", desc: "Fotokopi KTP, KIA, atau Kartu Keluarga" }
],
dataText: ["nama anak", "nama ayah", "status ayah", "nama ibu", "status ibu"]
dataText: ["nik", "nama", "tempat tanggal lahir", "jenis kelamin", "alamat", "pekerjaan", "nama ayah", "status ayah", "nama ibu", "status ibu"]
}
];

View File

@@ -1,25 +1,16 @@
import DashboardCountData from "@/components/DashboardCountData";
import DashboardGrafik from "@/components/DashboardGrafik";
import DashboardLastData from "@/components/DashboardLastData";
import {
Card,
Badge,
Container,
Flex,
Group,
Progress,
Stack,
Text,
Title,
Progress,
Badge,
Button,
Grid,
Divider,
Title
} from "@mantine/core";
import {
IconActivity,
IconUsers,
IconServer,
IconDatabase,
IconSettings,
IconArrowRight,
} from "@tabler/icons-react";
export default function Dashboard() {
return (
@@ -43,144 +34,15 @@ export default function Dashboard() {
Live
</Badge>
</Group>
<Button
variant="gradient"
gradient={{ from: "teal", to: "cyan", deg: 45 }}
radius="md"
rightSection={<IconArrowRight size={18} />}
style={{
boxShadow: "0 0 12px rgba(0,255,200,0.3)",
}}
>
View Details
</Button>
</Flex>
<Grid>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<MetricCard
icon={<IconUsers size={28} />}
label="Active Users"
value="1,248"
change="+12%"
color="teal"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<MetricCard
icon={<IconServer size={28} />}
label="Server Uptime"
value="99.98%"
change="+0.02%"
color="cyan"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<MetricCard
icon={<IconDatabase size={28} />}
label="Database Ops"
value="82.4K"
change="+5.6%"
color="blue"
/>
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 6, md: 3 }}>
<MetricCard
icon={<IconActivity size={28} />}
label="System Health"
value="Stable"
change=""
color="green"
/>
</Grid.Col>
</Grid>
<Card
radius="lg"
p="xl"
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 25px rgba(0,255,200,0.08)",
}}
>
<Stack gap="md">
<Flex align="center" justify="space-between">
<Title order={4} c="gray.0">
System Performance
</Title>
<IconSettings size={20} color="gray" />
</Flex>
<Divider my="xs" />
<Text size="sm" c="dimmed">
Resource usage and performance indicators.
</Text>
<Stack gap="sm" mt="md">
<ProgressSection label="CPU Usage" value={68} color="teal" />
<ProgressSection label="Memory Usage" value={75} color="cyan" />
<ProgressSection label="Network Load" value={42} color="blue" />
<ProgressSection label="Disk Space" value={88} color="red" />
</Stack>
</Stack>
</Card>
<DashboardCountData />
<DashboardGrafik />
<DashboardLastData />
</Stack>
</Container>
);
}
function MetricCard({
icon,
label,
value,
change,
color,
}: {
icon: React.ReactNode;
label: string;
value: string;
change?: string;
color: string;
}) {
return (
<Card
radius="lg"
p="md"
withBorder
style={{
background:
"linear-gradient(145deg, rgba(30,30,30,0.95), rgba(55,55,55,0.9))",
borderColor: "rgba(100,100,100,0.2)",
transition: "transform 0.15s ease, box-shadow 0.15s ease",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.boxShadow = "0 0 10px rgba(0,255,200,0.2)")
}
onMouseLeave={(e) => (e.currentTarget.style.boxShadow = "none")}
>
<Stack gap={6}>
<Group gap={6}>
{icon}
<Text size="sm" c="dimmed">
{label}
</Text>
</Group>
<Flex align="center" justify="space-between">
<Text fw={600} size="xl" c="gray.0">
{value}
</Text>
{change && (
<Text size="sm" c={color}>
{change}
</Text>
)}
</Flex>
</Stack>
</Card>
);
}
function ProgressSection({
label,
value,

View File

@@ -1,3 +1,4 @@
import ModalFile from "@/components/ModalFile";
import ModalSurat from "@/components/ModalSurat";
import notification from "@/components/notificationGlobal";
import apiFetch from "@/lib/apiFetch";
@@ -77,7 +78,9 @@ function DetailDataPengajuan({ data, syaratDokumen, dataText, onAction }: { data
const [host, setHost] = useState<User | null>(null);
const [noSurat, setNoSurat] = useState("");
const [openedPreview, setOpenedPreview] = useState(false);
const [openedPreviewFile, setOpenedPreviewFile] = useState(false);
const [permissions, setPermissions] = useState<JsonValue[]>([]);
const [viewImg, setViewImg] = useState("");
useEffect(() => {
async function fetchHost() {
@@ -128,8 +131,22 @@ function DetailDataPengajuan({ data, syaratDokumen, dataText, onAction }: { data
}
}
useShallowEffect(() => {
if (viewImg) {
setOpenedPreviewFile(true);
}
}, [viewImg]);
return (
<>
<ModalFile
open={openedPreviewFile && !_.isEmpty(viewImg)}
onClose={() => {
setOpenedPreviewFile(false)
}}
folder="syarat-dokumen"
fileName={viewImg}
/>
{/* MODAL KONFIRMASI */}
<Modal
@@ -246,7 +263,7 @@ function DetailDataPengajuan({ data, syaratDokumen, dataText, onAction }: { data
>
{syaratDokumen?.map((v: any) => (
<List.Item key={v.id}>
<Anchor href="https://mantine.dev/" target="_blank">
<Anchor onClick={() => { setViewImg(v.value) }}>
{v.jenis}
</Anchor>
</List.Item>

View File

@@ -1,3 +1,4 @@
import ModalFile from "@/components/ModalFile";
import notification from "@/components/notificationGlobal";
import apiFetch from "@/lib/apiFetch";
import {
@@ -10,13 +11,12 @@ import {
Flex,
Grid,
Group,
Image,
Modal,
Stack,
Table,
Text,
Textarea,
Title,
Title
} from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import {
@@ -73,9 +73,7 @@ export default function DetailPengaduanPage() {
function DetailDataPengaduan({ data, onAction }: { data: any, onAction: () => void }) {
const [opened, { open, close }] = useDisclosure(false);
const [catModal, setCatModal] = useState<"tolak" | "terima">("tolak");
const [imageSrc, setImageSrc] = useState<string | null>(null);
const [openedModalImage, { open: openModalImage, close: closeModalImage }] =
useDisclosure(false);
const [openedPreview, setOpenedPreview] = useState(false);
const [keterangan, setKeterangan] = useState("");
const [host, setHost] = useState<User | null>(null);
const [permissions, setPermissions] = useState<JsonValue[]>([]);
@@ -173,14 +171,12 @@ function DetailDataPengaduan({ data, onAction }: { data: any, onAction: () => vo
{/* MODAL GAMBAR */}
<Modal
opened={openedModalImage}
onClose={closeModalImage}
title="Gambar Pengaduan"
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Image src={imageSrc!} />
</Modal>
<ModalFile
open={openedPreview && !_.isEmpty(data?.image)}
onClose={() => setOpenedPreview(false)}
folder="pengaduan"
fileName={data?.image}
/>
<Card
radius="md"
@@ -266,7 +262,7 @@ function DetailDataPengaduan({ data, onAction }: { data: any, onAction: () => vo
{
data?.image != null && data?.image != ""
?
<Anchor href="#" onClick={() => { }}>
<Anchor href="#" onClick={() => { setOpenedPreview(true) }}>
Lihat Gambar
</Anchor>
:

View File

@@ -0,0 +1,278 @@
import Elysia from "elysia";
import { getLastUpdated } from "../lib/get-last-updated";
import { prisma } from "../lib/prisma";
const DashboardRoute = new Elysia({
prefix: "dashboard",
tags: ["dashboard"],
})
.get("/count", async () => {
// ---- RANGE HARI INI ----
const now = new Date();
const startOfToday = new Date(now);
startOfToday.setHours(0, 0, 0, 0);
const endOfToday = new Date(now);
endOfToday.setHours(23, 59, 59, 999);
// ---- RANGE KEMARIN ----
const yesterday = new Date(now);
yesterday.setDate(yesterday.getDate() - 1);
const startOfYesterday = new Date(yesterday);
startOfYesterday.setHours(0, 0, 0, 0);
const endOfYesterday = new Date(yesterday);
endOfYesterday.setHours(23, 59, 59, 999);
// ---- QUERY ----
const dataWarga = await prisma.warga.count();
// Pengaduan
const dataPengaduanToday = await prisma.pengaduan.count({
where: {
isActive: true,
status: "antrian",
createdAt: {
gte: startOfToday,
lte: endOfToday,
},
},
});
const dataPengaduanYesterday = await prisma.pengaduan.count({
where: {
isActive: true,
status: "antrian",
createdAt: {
gte: startOfYesterday,
lte: endOfYesterday,
},
},
});
const kenaikanPengaduan =
dataPengaduanYesterday === 0
? dataPengaduanToday > 0
? dataPengaduanToday * 100
: 0
: ((dataPengaduanToday - dataPengaduanYesterday) / dataPengaduanYesterday) * 100;
// Pelayanan
const dataPelayananToday = await prisma.pelayananAjuan.count({
where: {
isActive: true,
status: "antrian",
createdAt: {
gte: startOfToday,
lte: endOfToday,
},
},
});
const dataPelayananYesterday = await prisma.pelayananAjuan.count({
where: {
isActive: true,
status: "antrian",
createdAt: {
gte: startOfYesterday,
lte: endOfYesterday,
},
},
});
const kenaikanPelayanan =
dataPelayananYesterday === 0
? dataPelayananToday > 0
? dataPelayananToday * 100
: 0
: ((dataPelayananToday - dataPelayananYesterday) / dataPelayananYesterday) * 100;
// ---- FINAL OUTPUT ----
const dataFix = {
warga: dataWarga,
pengaduan: {
today: dataPengaduanToday,
yesterday: dataPengaduanYesterday,
kenaikan: Number(kenaikanPengaduan.toFixed(2)), // dalam persen
},
pelayanan: {
today: dataPelayananToday,
yesterday: dataPelayananYesterday,
kenaikan: Number(kenaikanPelayanan.toFixed(2)), // dalam persen
},
};
return dataFix;
}, {
detail: {
summary: "Dashboard - Menghitung Data",
description: `tool untuk menghitung data pengaduan dan pelayanan yg masuk hari ini dan data warga`,
}
})
.get("/last-update", async () => {
const dataPengaduan = await prisma.pengaduan.findMany({
skip: 0,
take: 5,
orderBy: {
updatedAt: "desc",
},
})
const dataPengaduanFix = dataPengaduan.map((item) => {
return {
noPengaduan: item.noPengaduan,
id: item.id,
title: item.title,
status: item.status,
updatedAt: 'terakhir diperbarui ' + getLastUpdated(item.updatedAt),
}
})
const dataPelayanan = await prisma.pelayananAjuan.findMany({
skip: 0,
take: 5,
orderBy: {
updatedAt: "desc",
},
select: {
id: true,
status: true,
noPengajuan: true,
updatedAt: true,
CategoryPelayanan: {
select: {
name: true,
}
}
}
})
const dataPelayananFix = dataPelayanan.map((item) => {
return {
id: item.id,
noPengajuan: item.noPengajuan,
title: item.CategoryPelayanan.name,
status: item.status,
updatedAt: 'terakhir diperbarui ' + getLastUpdated(item.updatedAt),
}
})
const dataFix = {
pengaduan: dataPengaduanFix,
pelayanan: dataPelayananFix,
}
return dataFix;
}, {
detail: {
summary: "Dashboard - List data pengaduan dan pelayanan terupdate",
description: `tool untuk mendapatkan list data pengaduan dan pelayanan yg terupdate`,
}
})
.get("/grafik", async () => {
const now = new Date();
const start7Days = new Date(now);
start7Days.setDate(start7Days.getDate() - 7);
start7Days.setHours(0, 0, 0, 0);
const endToday = new Date(now);
endToday.setHours(23, 59, 59, 999);
// Ambil semua data pengaduan & pelayanan dalam 7 hari
const pengaduan = await prisma.pengaduan.findMany({
where: {
createdAt: {
gte: start7Days,
lte: endToday,
},
isActive: true
},
select: {
createdAt: true
}
});
const pelayanan = await prisma.pelayananAjuan.findMany({
where: {
createdAt: {
gte: start7Days,
lte: endToday,
},
isActive: true
},
select: {
createdAt: true
}
});
// --- BUAT RANGE TANGGAL 7 HARI ---
const resultMap: Record<string, { pengaduan: number; pelayanan: number }> = {};
for (let i = 0; i < 8; i++) {
const d = new Date(start7Days);
d.setDate(d.getDate() + i);
const formatted = d.toLocaleDateString("id-ID", {
day: "numeric",
month: "long"
});
resultMap[formatted] = { pengaduan: 0, pelayanan: 0 };
}
// --- HITUNG PENGADUAN PER HARI ---
pengaduan.forEach((item) => {
const t = item.createdAt.toLocaleDateString("id-ID", {
day: "numeric",
month: "long"
});
if (resultMap[t]) {
resultMap[t].pengaduan += 1;
}
});
// --- HITUNG PELAYANAN PER HARI ---
pelayanan.forEach((item) => {
const t = item.createdAt.toLocaleDateString("id-ID", {
day: "numeric",
month: "long"
});
if (resultMap[t]) {
resultMap[t].pelayanan += 1;
}
});
// --- KONVERSI KE FORMAT FINAL ---
const source = Object.keys(resultMap).map((tanggal) => ({
tanggal,
pengaduan: resultMap[tanggal]?.pengaduan,
pelayanan: resultMap[tanggal]?.pelayanan,
}));
return {
dimensions: ["tanggal", "pengaduan", "pelayanan"],
source,
};
}, {
detail: {
summary: "Dashboard - Grafik data pengaduan dan pelayanan",
description: `tool untuk mendapatkan grafik data pengaduan dan pelayanan`,
}
})
;
;
export default DashboardRoute

View File

@@ -436,13 +436,13 @@ const PelayananRoute = new Elysia({
jenis: t.String({
minLength: 1,
description: "Jenis field yang dibutuhkan oleh kategori pelayanan. Biasanya dinamis.",
examples: ["nama", "alamat", "pekerjaan", "keperluan"],
examples: ["nama", "jenis kelamin", "tempat tanggal lahir", "negara", "agama", "status perkawinan", "alamat", "pekerjaan", "jenis usaha", "alamat usaha"],
error: "jenis harus diisi"
}),
value: t.String({
minLength: 1,
description: "Isi atau nilai dari jenis field terkait.",
examples: ["Budi Santoso", "Jl. Mawar No. 10", "Karyawan Swasta"],
examples: ["Budi Santoso", "Laki-laki", "Denpasar, 28 Februari 1990", "Indonesia", "Islam", "Belum menikah", "Jl. Mawar No. 10", "Karyawan Swasta", "usaha makanan", "Jl. Melati No. 21"],
error: "value harus diisi"
}),
}),
@@ -450,6 +450,14 @@ const PelayananRoute = new Elysia({
description: "Kumpulan data text dinamis sesuai kategori layanan.",
examples: [
[
{ jenis: "nama", value: "Budi Santoso" },
{ jenis: "jenis kelamin", value: "Laki-laki" },
{ jenis: "tempat tanggal lahir", value: "Denpasar, 28 Februari 1990" },
{ jenis: "negara", value: "Indonesia" },
{ jenis: "agama", value: "Islam" },
{ jenis: "status perkawinan", value: "Belum menikah" },
{ jenis: "alamat", value: "Jl. Mawar No. 10" },
{ jenis: "pekerjaan", value: "Karyawan Swasta" },
{ jenis: "jenis usaha", value: "usaha makanan" },
{ jenis: "alamat usaha", value: "Jl. Melati No. 21" },
]

View File

@@ -1,5 +1,6 @@
import Elysia, { t } from "elysia";
import type { User } from "generated/prisma";
import _ from "lodash";
import { prisma } from "../lib/prisma";
const UserRoute = new Elysia({
@@ -145,10 +146,31 @@ const UserRoute = new Elysia({
NOT: {
id: user.id
}
},
select: {
id: true,
name: true,
phone: true,
email: true,
roleId: true,
Role: {
select: {
name: true
}
}
}
})
return data
const dataFix = data.map((item: any) => ({
..._.omit(item, ["Role"]),
nameRole: item.Role?.name,
name: String(item.name),
phone: String(item.phone),
email: String(item.email),
roleId: String(item.roleId),
}))
return dataFix
}, {
detail: {
summary: "list",