Compare commits
76 Commits
amalia/26-
...
amalia/10-
| Author | SHA1 | Date | |
|---|---|---|---|
| 6bc6a9d357 | |||
| dee32b8cfd | |||
| ff0b0273bf | |||
| f8dcffa9c5 | |||
| 20e3056e04 | |||
| 84c9f405d6 | |||
| 22597c0159 | |||
| 0f9af404e1 | |||
| 676edaa22b | |||
| 7f6f495eaa | |||
| b5af41b07d | |||
| 6428f5084e | |||
| bfc292ec6c | |||
| 3b71976863 | |||
| 5680466c98 | |||
| 270f3687a3 | |||
| f5cc45937c | |||
| 5b4164b151 | |||
| 225c58b346 | |||
| b8b3aed86e | |||
| fc530399dd | |||
| 281e34ea69 | |||
| f928fc504f | |||
| 4fb98d0480 | |||
| bfb33e2105 | |||
| 2579714000 | |||
| d69189cf7d | |||
| 20e24a03aa | |||
| c256f4b729 | |||
| 9430ad3728 | |||
| c6c3ba95f8 | |||
|
|
3c58230c3a | ||
| d22b4b973f | |||
| 700fbe3bd7 | |||
| 9b7a61e134 | |||
| 2d376663bb | |||
| 7c669f3494 | |||
| 0ed9dc6ddd | |||
| b9984c6337 | |||
| 48a7d43713 | |||
| 6a52d10faa | |||
| 2b94684570 | |||
| cc7c8eb704 | |||
| 4996da4189 | |||
| c32cce838f | |||
| 5af9b720ca | |||
| 35618bb438 | |||
| eee8aadb1a | |||
| 1f95c7d7d8 | |||
| 23df516aad | |||
| 70175cedc6 | |||
| f52f5f87ca | |||
| ea17357638 | |||
| 9c7c9d8595 | |||
| 5ecf264155 | |||
| 4cc28c4311 | |||
| c25e5eeba0 | |||
| 574603e290 | |||
| ba76eb5e59 | |||
| 6ae83ec19c | |||
| ba0414a99c | |||
| 4dd66dbd9a | |||
| fa201274d4 | |||
| dff1aa61c5 | |||
| 90b8fdf573 | |||
| 239b1bdc1b | |||
| f99da3b2a6 | |||
| 3866f71e2d | |||
| d57ff92aa9 | |||
| f901cff3b1 | |||
| fe3ebf4bd3 | |||
| 075cb12417 | |||
| cd7e602254 | |||
| f9b84f89eb | |||
| cca1840922 | |||
| c622565bb7 |
23
bun.lock
23
bun.lock
@@ -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=="],
|
||||
|
||||
3
kirim.sh
Normal file
3
kirim.sh
Normal file
@@ -0,0 +1,3 @@
|
||||
curl -X POST https://cld-dkr-prod-jenna-mcp.wibudev.com/api/pengaduan/upload-file-form-data \
|
||||
-H "Accept: application/json" \
|
||||
-F "file=@image.png"
|
||||
@@ -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",
|
||||
|
||||
98
src/components/DashboardCountData.tsx
Normal file
98
src/components/DashboardCountData.tsx
Normal 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={"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="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>
|
||||
);
|
||||
}
|
||||
83
src/components/DashboardGrafik.tsx
Normal file
83
src/components/DashboardGrafik.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
133
src/components/DashboardLastData.tsx
Normal file
133
src/components/DashboardLastData.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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"}>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
@@ -378,7 +395,17 @@ function DetailDataHistori({ data }: { data: any }) {
|
||||
{
|
||||
data?.map((item: any) => (
|
||||
<Table.Tr key={item.id}>
|
||||
<Table.Td style={{ whiteSpace: "nowrap" }}>{item.createdAt}</Table.Td>
|
||||
<Table.Td style={{ whiteSpace: "nowrap" }}>
|
||||
{
|
||||
item.createdAt.toLocaleString("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false
|
||||
})
|
||||
}</Table.Td>
|
||||
<Table.Td>{item.deskripsi}</Table.Td>
|
||||
<Table.Td>{item.status}</Table.Td>
|
||||
<Table.Td style={{ whiteSpace: "nowrap" }}>{item.nameUser ? item.nameUser : "-"}</Table.Td>
|
||||
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
Container,
|
||||
Divider,
|
||||
Flex,
|
||||
Grid,
|
||||
Group,
|
||||
Input,
|
||||
Pagination,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
@@ -113,22 +115,26 @@ type StatusKey =
|
||||
function ListPelayananSurat({ status }: { status: StatusKey }) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [value, setValue] = useState("");
|
||||
const { data, mutate, isLoading } = useSwr("/", async () => {
|
||||
const res = await apiFetch.api.pelayanan.list.get({
|
||||
const { data, mutate, isLoading } = useSwr("/", async () =>
|
||||
apiFetch.api.pelayanan.list.get({
|
||||
query: {
|
||||
status,
|
||||
search: value,
|
||||
take: "",
|
||||
page: "",
|
||||
page: page.toString(),
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
useShallowEffect(() => {
|
||||
setPage(1);
|
||||
mutate();
|
||||
}, [status, value]);
|
||||
|
||||
return Array.isArray(res?.data) ? res.data : []; // ⬅ paksa return array
|
||||
});
|
||||
|
||||
useShallowEffect(() => {
|
||||
mutate();
|
||||
}, [status, value]);
|
||||
}, [page]);
|
||||
|
||||
|
||||
useShallowEffect(() => {
|
||||
@@ -155,26 +161,39 @@ function ListPelayananSurat({ status }: { status: StatusKey }) {
|
||||
</Card>
|
||||
);
|
||||
|
||||
const list = data || [];
|
||||
const list = data?.data?.data || [];
|
||||
const total = data?.data?.total || 0;
|
||||
const totalPage = data?.data?.totalPages || 1;
|
||||
const pageSize = data?.data?.pageSize || 10;
|
||||
const pageNow = data?.data?.page || 1;
|
||||
const toDate = (d: any) => new Date(d);
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<Group grow>
|
||||
<Input
|
||||
value={value}
|
||||
placeholder="Cari pengajuan..."
|
||||
onChange={(event) => setValue(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
rightSectionPointerEvents="all"
|
||||
rightSection={
|
||||
<CloseButton
|
||||
aria-label="Clear input"
|
||||
onClick={() => setValue("")}
|
||||
style={{ display: value ? undefined : "none" }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Group>
|
||||
<Grid>
|
||||
<Grid.Col span={9}>
|
||||
<Input
|
||||
value={value}
|
||||
placeholder="Cari pengajuan..."
|
||||
onChange={(event) => setValue(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
rightSectionPointerEvents="all"
|
||||
rightSection={
|
||||
<CloseButton
|
||||
aria-label="Clear input"
|
||||
onClick={() => setValue("")}
|
||||
style={{ display: value ? undefined : "none" }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={3}>
|
||||
<Group justify="flex-end">
|
||||
<Text size="sm" c="gray.5">{`${pageSize * (page - 1) + 1} – ${Math.min(total, pageSize * page)} of ${total}`}</Text>
|
||||
<Pagination total={totalPage} value={page} onChange={setPage} withPages={false} />
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
{Array.isArray(list) && list?.length === 0 ? (
|
||||
<Flex justify="center" align="center" py={"xl"}>
|
||||
<Stack gap={4} align="center">
|
||||
@@ -214,7 +233,7 @@ function ListPelayananSurat({ status }: { status: StatusKey }) {
|
||||
#{v.noPengajuan}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{v.updatedAt}
|
||||
{String(v.updatedAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Flex>
|
||||
@@ -247,7 +266,7 @@ function ListPelayananSurat({ status }: { status: StatusKey }) {
|
||||
Tanggal Ajuan
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="md">{v.createdAt}</Text>
|
||||
<Text size="md">{toDate(v.createdAt).toLocaleDateString("id-ID", { day: "numeric", month: "long", year: "numeric" })}</Text>
|
||||
</Flex>
|
||||
<Flex direction={"column"} justify="flex-start">
|
||||
<Group gap="xs">
|
||||
|
||||
@@ -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>
|
||||
:
|
||||
@@ -398,7 +394,16 @@ function DetailDataHistori({ data }: { data: any }) {
|
||||
{
|
||||
data?.map((item: any) => (
|
||||
<Table.Tr key={item.id}>
|
||||
<Table.Td style={{ whiteSpace: "nowrap" }}>{item.createdAt}</Table.Td>
|
||||
<Table.Td style={{ whiteSpace: "nowrap" }}>{
|
||||
item.createdAt.toLocaleString("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false
|
||||
})
|
||||
}</Table.Td>
|
||||
<Table.Td>{item.deskripsi}</Table.Td>
|
||||
<Table.Td>{item.status}</Table.Td>
|
||||
<Table.Td style={{ whiteSpace: "nowrap" }}>{item.nameUser ? item.nameUser : "-"}</Table.Td>
|
||||
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
Container,
|
||||
Divider,
|
||||
Flex,
|
||||
Grid,
|
||||
Group,
|
||||
Input,
|
||||
Pagination,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
@@ -124,22 +126,25 @@ function ListPengaduan({ status }: { status: StatusKey }) {
|
||||
const navigate = useNavigate();
|
||||
const [page, setPage] = useState(1);
|
||||
const [value, setValue] = useState("");
|
||||
const { data, mutate, isLoading } = useSwr("/", async () => {
|
||||
const res = await apiFetch.api.pengaduan.list.get({
|
||||
const { data, mutate, isLoading } = useSwr("/", async () =>
|
||||
apiFetch.api.pengaduan.list.get({
|
||||
query: {
|
||||
status,
|
||||
search: value,
|
||||
take: "",
|
||||
page: "",
|
||||
page: page.toString(),
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
return Array.isArray(res?.data) ? res.data : []; // ⬅ paksa return array
|
||||
});
|
||||
useShallowEffect(() => {
|
||||
setPage(1);
|
||||
mutate();
|
||||
}, [status, value]);
|
||||
|
||||
useShallowEffect(() => {
|
||||
mutate();
|
||||
}, [status, value]);
|
||||
}, [page]);
|
||||
|
||||
useShallowEffect(() => {
|
||||
const unsubscribe = subscribe(state, () => mutate());
|
||||
@@ -163,31 +168,41 @@ function ListPengaduan({ status }: { status: StatusKey }) {
|
||||
</Card>
|
||||
);
|
||||
|
||||
const list = data || [];
|
||||
const list = data?.data?.data || [];
|
||||
const total = data?.data?.total || 0;
|
||||
const totalPage = data?.data?.totalPages || 1;
|
||||
const pageSize = data?.data?.pageSize || 10;
|
||||
const pageNow = data?.data?.page || 1;
|
||||
const toDate = (d: any) => new Date(d);
|
||||
|
||||
|
||||
return (
|
||||
<Stack gap="xl">
|
||||
<Group grow>
|
||||
<Input
|
||||
value={value}
|
||||
placeholder="Cari pengaduan..."
|
||||
onChange={(event) => setValue(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
rightSectionPointerEvents="all"
|
||||
rightSection={
|
||||
<CloseButton
|
||||
aria-label="Clear input"
|
||||
onClick={() => setValue("")}
|
||||
style={{ display: value ? undefined : "none" }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{/* <Group justify="flex-end">
|
||||
<Text size="sm">Menampilkan {Number(data?.data?.length) * (page - 1) + 1} – {Math.min(10, Number(data?.data?.length) * page)} dari {Number(data?.data?.length)}</Text>
|
||||
<Pagination total={Number(data?.data?.length)} value={page} onChange={setPage} withPages={false} />
|
||||
</Group> */}
|
||||
</Group>
|
||||
{list.length === 0 ? (
|
||||
<Grid>
|
||||
<Grid.Col span={9}>
|
||||
<Input
|
||||
value={value}
|
||||
placeholder="Cari pengaduan..."
|
||||
onChange={(event) => setValue(event.currentTarget.value)}
|
||||
leftSection={<IconSearch size={16} />}
|
||||
rightSectionPointerEvents="all"
|
||||
rightSection={
|
||||
<CloseButton
|
||||
aria-label="Clear input"
|
||||
onClick={() => setValue("")}
|
||||
style={{ display: value ? undefined : "none" }}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</Grid.Col>
|
||||
<Grid.Col span={3}>
|
||||
<Group justify="flex-end">
|
||||
<Text size="sm" c="gray.5">{`${pageSize * (page - 1) + 1} – ${Math.min(total, pageSize * page)} of ${total}`}</Text>
|
||||
<Pagination total={totalPage} value={page} onChange={setPage} withPages={false} />
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
</Grid>
|
||||
{Array.isArray(list) && list.length === 0 ? (
|
||||
<Flex justify="center" align="center" py={"xl"}>
|
||||
<Stack gap={4} align="center">
|
||||
<IconFileSad size={32} color="gray" />
|
||||
@@ -224,7 +239,7 @@ function ListPengaduan({ status }: { status: StatusKey }) {
|
||||
#{v.noPengaduan}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{v.updatedAt}
|
||||
{String(v.updatedAt)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Flex>
|
||||
@@ -257,7 +272,7 @@ function ListPengaduan({ status }: { status: StatusKey }) {
|
||||
Tanggal Aduan
|
||||
</Text>
|
||||
</Group>
|
||||
<Text size="md">{v.createdAt}</Text>
|
||||
<Text size="md">{toDate(v.createdAt).toLocaleDateString("id-ID", { day: "numeric", month: "long", year: "numeric" })}</Text>
|
||||
</Flex>
|
||||
<Flex direction={"column"} justify="flex-start">
|
||||
<Group gap="xs">
|
||||
|
||||
@@ -6,9 +6,12 @@ import {
|
||||
Container,
|
||||
Divider,
|
||||
Flex,
|
||||
Group,
|
||||
Input,
|
||||
Pagination,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useShallowEffect } from "@mantine/hooks";
|
||||
@@ -19,21 +22,32 @@ import useSWR from "swr";
|
||||
|
||||
export default function ListWargaPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data, mutate, isLoading } = useSWR("/", () =>
|
||||
const [pages, setPages] = useState(1);
|
||||
const [value, setValue] = useState("");
|
||||
const { data, mutate } = useSWR("/", () =>
|
||||
apiFetch.api.warga.list.get({
|
||||
query: {
|
||||
search: value,
|
||||
page: pages,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const list = data?.data || [];
|
||||
const list = data?.data?.data || [];
|
||||
const total = data?.data?.total || 0;
|
||||
const totalPage = data?.data?.totalPages || 1;
|
||||
const pageSize = data?.data?.pageSize || 10;
|
||||
const pageNow = data?.data?.page || 1;
|
||||
|
||||
const [value, setValue] = useState("");
|
||||
|
||||
useShallowEffect(() => {
|
||||
setPages(1);
|
||||
mutate();
|
||||
}, [value]);
|
||||
|
||||
useShallowEffect(() => {
|
||||
mutate();
|
||||
}, [value]);
|
||||
}, [pages]);
|
||||
|
||||
|
||||
return (
|
||||
@@ -48,10 +62,10 @@ export default function ListWargaPage() {
|
||||
}}
|
||||
>
|
||||
<Stack gap="md">
|
||||
<Title order={3} c="gray.2">
|
||||
List Data Warga
|
||||
</Title>
|
||||
<Flex align="center" justify="space-between">
|
||||
<Title order={3} c="gray.2">
|
||||
List Data Warga
|
||||
</Title>
|
||||
<Input
|
||||
value={value}
|
||||
placeholder="Cari warga..."
|
||||
@@ -66,6 +80,10 @@ export default function ListWargaPage() {
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Group>
|
||||
<Text size="sm">{`${pageSize * (pages - 1) + 1} – ${Math.min(total, pageSize * pages)} of ${total}`}</Text>
|
||||
<Pagination total={totalPage} value={pages} onChange={setPages} withPages={false} />
|
||||
</Group>
|
||||
</Flex>
|
||||
<Divider my={0} />
|
||||
<Table>
|
||||
@@ -86,8 +104,8 @@ export default function ListWargaPage() {
|
||||
Array.isArray(list) && list?.map((item, i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td>{item.name}</Table.Td>
|
||||
<Table.Td>{item.phone}</Table.Td>
|
||||
<Table.Td>
|
||||
<Table.Td w={250}>{item.phone}</Table.Td>
|
||||
<Table.Td w={150}>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
export function isValidPhone(number: string): boolean {
|
||||
const clean = number.replace(/[\s.-]/g, ""); // hapus spasi, titik, strip
|
||||
const regex = /^(?:\+62|62|0)8\d{7,12}$/;
|
||||
return regex.test(clean);
|
||||
}
|
||||
|
||||
export function normalizePhoneNumber({ phone }: { phone: string }) {
|
||||
// Hapus semua spasi, tanda hubung, atau karakter non-digit (+ tetap dipertahankan untuk dicek)
|
||||
let cleaned = phone.trim().replace(/[\s-]/g, "");
|
||||
let cleaned = phone.trim().replace(/[\s.-]/g, "");
|
||||
|
||||
// Jika diawali dengan +62 → ganti jadi 62
|
||||
if (cleaned.startsWith("+62")) {
|
||||
|
||||
278
src/server/routes/dashboard_route.ts
Normal file
278
src/server/routes/dashboard_route.ts
Normal 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
|
||||
@@ -128,7 +128,8 @@ function convertToMcpContent(payload: any) {
|
||||
export async function executeTool(
|
||||
tool: any,
|
||||
args: Record<string, any> = {},
|
||||
baseUrl: string
|
||||
baseUrl: string,
|
||||
xPayload: Record<string, any> = {}
|
||||
) {
|
||||
const x = tool["x-props"] || {};
|
||||
const method = (x.method || "GET").toUpperCase();
|
||||
@@ -247,6 +248,9 @@ export async function executeTool(
|
||||
|
||||
// Execute fetch
|
||||
console.log(`[MCP] → ${method} ${url}`);
|
||||
for(const [key, value] of Object.entries(xPayload)) {
|
||||
opts.headers![key] = value;
|
||||
}
|
||||
const res = await fetch(url, opts);
|
||||
|
||||
const resContentType = (res.headers.get("content-type") || "").toLowerCase();
|
||||
@@ -281,7 +285,7 @@ export async function executeTool(
|
||||
/* -------------------------
|
||||
JSON-RPC Handler
|
||||
------------------------- */
|
||||
async function handleMCPRequestAsync(request: JSONRPCRequest): Promise<JSONRPCResponse> {
|
||||
async function handleMCPRequestAsync(request: JSONRPCRequest, xPayload: Record<string, any>): Promise<JSONRPCResponse> {
|
||||
const { id, method, params } = request;
|
||||
|
||||
const makeError = (code: number, message: string, data?: any): JSONRPCResponse => ({
|
||||
@@ -331,7 +335,7 @@ async function handleMCPRequestAsync(request: JSONRPCRequest): Promise<JSONRPCRe
|
||||
const baseUrl = (params?.credentials?.baseUrl as string) || process.env.BUN_PUBLIC_BASE_URL || "http://localhost:3000";
|
||||
const args = params?.arguments || {};
|
||||
|
||||
const result = await executeTool(tool, args, baseUrl);
|
||||
const result = await executeTool(tool, args, baseUrl, xPayload);
|
||||
|
||||
// Extract the meaningful payload (prefer nested .data if present)
|
||||
const raw = extractRaw(result.data);
|
||||
@@ -365,7 +369,7 @@ async function handleMCPRequestAsync(request: JSONRPCRequest): Promise<JSONRPCRe
|
||||
Elysia App & Routes
|
||||
------------------------- */
|
||||
export const MCPRoute = new Elysia({ tags: ["MCP Server"] })
|
||||
.post("/mcp", async ({ request, set }) => {
|
||||
.post("/mcp", async ({ request, set, headers }) => {
|
||||
set.headers["Content-Type"] = "application/json";
|
||||
set.headers["Access-Control-Allow-Origin"] = "*";
|
||||
|
||||
@@ -378,12 +382,17 @@ export const MCPRoute = new Elysia({ tags: ["MCP Server"] })
|
||||
}
|
||||
}
|
||||
|
||||
const xPayload = {
|
||||
['x-user']: headers['x-user'] || "",
|
||||
['x-phone']: headers['x-phone'] || ""
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// If batch array -> allSettled for resilience
|
||||
if (Array.isArray(body)) {
|
||||
const promises = body.map((req: JSONRPCRequest) => handleMCPRequestAsync(req));
|
||||
const promises = body.map((req: JSONRPCRequest) => handleMCPRequestAsync(req, xPayload));
|
||||
const settled = await Promise.allSettled(promises);
|
||||
const responses = settled.map((s) =>
|
||||
s.status === "fulfilled"
|
||||
@@ -401,7 +410,7 @@ export const MCPRoute = new Elysia({ tags: ["MCP Server"] })
|
||||
return responses;
|
||||
}
|
||||
|
||||
const single = await handleMCPRequestAsync(body as JSONRPCRequest);
|
||||
const single = await handleMCPRequestAsync(body as JSONRPCRequest, xPayload);
|
||||
return single;
|
||||
} catch (err: any) {
|
||||
set.status = 400;
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { StatusPengaduan } from "generated/prisma"
|
||||
import { createSurat } from "../lib/create-surat"
|
||||
import { getLastUpdated } from "../lib/get-last-updated"
|
||||
import { generateNoPengajuanSurat } from "../lib/no-pengajuan-surat"
|
||||
import { normalizePhoneNumber } from "../lib/normalizePhone"
|
||||
import { isValidPhone, normalizePhoneNumber } from "../lib/normalizePhone"
|
||||
import { prisma } from "../lib/prisma"
|
||||
|
||||
const PelayananRoute = new Elysia({
|
||||
@@ -250,14 +250,7 @@ const PelayananRoute = new Elysia({
|
||||
id: item.id,
|
||||
deskripsi: item.deskripsi,
|
||||
status: item.status,
|
||||
createdAt: item.createdAt.toLocaleString("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false
|
||||
}),
|
||||
createdAt: item.createdAt,
|
||||
idUser: item.idUser,
|
||||
nameUser: item.User?.name,
|
||||
}
|
||||
@@ -298,11 +291,14 @@ const PelayananRoute = new Elysia({
|
||||
tags: ["mcp"]
|
||||
}
|
||||
})
|
||||
.post("/create", async ({ body }) => {
|
||||
const { kategoriId, wargaId, noTelepon, dataText, syaratDokumen } = body
|
||||
.post("/create", async ({ body, headers }) => {
|
||||
const { kategoriId, dataText, syaratDokumen } = body
|
||||
const namaWarga = headers['x-user'] || ""
|
||||
const noTelepon = headers['x-phone'] || ""
|
||||
const noPengajuan = await generateNoPengajuanSurat()
|
||||
let idCategoryFix = kategoriId
|
||||
let idWargaFix = wargaId
|
||||
let idWargaFix = ""
|
||||
|
||||
const category = await prisma.categoryPelayanan.findUnique({
|
||||
where: {
|
||||
id: kategoriId,
|
||||
@@ -324,36 +320,28 @@ const PelayananRoute = new Elysia({
|
||||
|
||||
}
|
||||
|
||||
const warga = await prisma.warga.findUnique({
|
||||
if (!isValidPhone(noTelepon)) {
|
||||
return { success: false, message: 'nomor telepon tidak valid, harap masukkan nomor yang benar' }
|
||||
}
|
||||
|
||||
const nomorHP = normalizePhoneNumber({ phone: noTelepon })
|
||||
const dataWarga = await prisma.warga.upsert({
|
||||
where: {
|
||||
id: wargaId,
|
||||
phone: nomorHP
|
||||
},
|
||||
create: {
|
||||
name: namaWarga,
|
||||
phone: nomorHP,
|
||||
},
|
||||
update: {
|
||||
name: namaWarga,
|
||||
},
|
||||
select: {
|
||||
id: true
|
||||
}
|
||||
})
|
||||
|
||||
if (!warga) {
|
||||
const nomorHP = normalizePhoneNumber({ phone: noTelepon })
|
||||
const cariWarga = await prisma.warga.findFirst({
|
||||
where: {
|
||||
phone: nomorHP,
|
||||
}
|
||||
})
|
||||
|
||||
if (!cariWarga) {
|
||||
const wargaCreate = await prisma.warga.create({
|
||||
data: {
|
||||
name: wargaId,
|
||||
phone: nomorHP,
|
||||
},
|
||||
select: {
|
||||
id: true
|
||||
}
|
||||
})
|
||||
idWargaFix = wargaCreate.id
|
||||
} else {
|
||||
idWargaFix = cariWarga.id
|
||||
}
|
||||
|
||||
}
|
||||
idWargaFix = dataWarga.id
|
||||
|
||||
const pengaduan = await prisma.pelayananAjuan.create({
|
||||
data: {
|
||||
@@ -391,6 +379,7 @@ const PelayananRoute = new Elysia({
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
await prisma.syaratDokumenPelayanan.createMany({
|
||||
data: dataInsertSyaratDokumen,
|
||||
})
|
||||
@@ -407,42 +396,36 @@ const PelayananRoute = new Elysia({
|
||||
}
|
||||
})
|
||||
|
||||
return { success: true, message: 'pengajuan surat sudah dibuat' }
|
||||
return { success: true, message: 'pengajuan layanan surat sudah dibuat dengan nomer ' + noPengajuan + ', nomer ini akan digunakan untuk mengakses pengajuan ini' }
|
||||
}, {
|
||||
body: t.Object({
|
||||
kategoriId: t.String({
|
||||
minLength: 1,
|
||||
description: "ID atau nama kategori pelayanan surat yang dipilih. Jika berupa nama, sistem akan mencocokkan secara otomatis.",
|
||||
examples: ["skusaha"],
|
||||
error: "ID kategori harus diisi"
|
||||
}),
|
||||
// namaWarga: t.String({
|
||||
// description: "Nama warga",
|
||||
// examples: ["Budi Santoso"],
|
||||
// error: "Nama warga harus diisi"
|
||||
// }),
|
||||
|
||||
wargaId: t.String({
|
||||
minLength: 1,
|
||||
description: "ID warga atau nama warga. Jika ID tidak ditemukan, sistem akan mencari berdasarkan nama.",
|
||||
examples: ["Budi Santoso"],
|
||||
error: "ID warga harus diisi"
|
||||
}),
|
||||
|
||||
noTelepon: t.String({
|
||||
minLength: 8,
|
||||
description: "Nomor HP warga yang akan dinormalisasi. Jika data warga tidak ditemukan berdasarkan idWarga, pencarian dilakukan via nomor ini.",
|
||||
examples: ["081234567890"],
|
||||
error: "Nomor telepon harus diisi"
|
||||
}),
|
||||
// noTelepon: t.String({
|
||||
// error: "Nomor telepon harus diisi",
|
||||
// examples: ["08123456789", "+628123456789"],
|
||||
// description: "Nomor telepon warga pelapor"
|
||||
// }),
|
||||
|
||||
dataText: t.Array(
|
||||
t.Object({
|
||||
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 +433,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" },
|
||||
]
|
||||
@@ -461,13 +452,11 @@ const PelayananRoute = new Elysia({
|
||||
syaratDokumen: t.Array(
|
||||
t.Object({
|
||||
jenis: t.String({
|
||||
minLength: 1,
|
||||
description: "Jenis dokumen persyaratan yang diminta oleh kategori layanan.",
|
||||
examples: ["ktp", "kk", "surat_pengantar_rt"],
|
||||
error: "jenis harus diisi"
|
||||
}),
|
||||
value: t.String({
|
||||
minLength: 1,
|
||||
description: "Nama file atau identifier file dokumen yang diupload.",
|
||||
examples: ["ktp_budi.png", "kk_budi.png"],
|
||||
error: "value harus diisi"
|
||||
@@ -487,7 +476,7 @@ const PelayananRoute = new Elysia({
|
||||
),
|
||||
}),
|
||||
detail: {
|
||||
summary: "Create Pengajuan Pelayanan Surat",
|
||||
summary: "Buat Pengajuan Pelayanan Surat",
|
||||
description: `tool untuk membuat pengajuan pelayanan surat dengan syarat dokumen serta data text sesuai kategori pelayanan surat yang dipilih`,
|
||||
tags: ["mcp"]
|
||||
}
|
||||
@@ -580,6 +569,14 @@ const PelayananRoute = new Elysia({
|
||||
mode: "insensitive"
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
Warga: {
|
||||
name: {
|
||||
contains: search ?? "",
|
||||
mode: "insensitive"
|
||||
},
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -591,6 +588,11 @@ const PelayananRoute = new Elysia({
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const totalData = await prisma.pelayananAjuan.count({
|
||||
where
|
||||
});
|
||||
|
||||
const data = await prisma.pelayananAjuan.findMany({
|
||||
skip,
|
||||
take: !take ? 10 : Number(take),
|
||||
@@ -624,12 +626,20 @@ const PelayananRoute = new Elysia({
|
||||
category: item.CategoryPelayanan.name,
|
||||
warga: item.Warga.name,
|
||||
status: item.status,
|
||||
createdAt: item.createdAt.toLocaleDateString("id-ID", { day: "numeric", month: "long", year: "numeric" }),
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
updatedAt: 'terakhir diperbarui ' + getLastUpdated(item.updatedAt),
|
||||
}
|
||||
})
|
||||
|
||||
return dataFix
|
||||
const dataReturn = {
|
||||
data: dataFix,
|
||||
total: totalData,
|
||||
page: Number(page) || 1,
|
||||
pageSize: !take ? 10 : Number(take),
|
||||
totalPages: Math.ceil(totalData / (!take ? 10 : Number(take)))
|
||||
}
|
||||
|
||||
return dataReturn
|
||||
}, {
|
||||
query: t.Object({
|
||||
take: t.String({ optional: true }),
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import Elysia, { t } from "elysia"
|
||||
import type { StatusPengaduan } from "generated/prisma"
|
||||
import _ from "lodash"
|
||||
import _, { head } 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 { normalizePhoneNumber } from "../lib/normalizePhone"
|
||||
import { isValidPhone, normalizePhoneNumber } from "../lib/normalizePhone"
|
||||
import { prisma } from "../lib/prisma"
|
||||
import { renameFile } from "../lib/rename-file"
|
||||
import { catFile, defaultConfigSF, removeFile, uploadFile, uploadFileBase64 } from "../lib/seafile"
|
||||
import { catFile, defaultConfigSF, removeFile, uploadFile, uploadFileToFolder } from "../lib/seafile"
|
||||
|
||||
const PengaduanRoute = new Elysia({
|
||||
prefix: "pengaduan",
|
||||
@@ -107,8 +107,10 @@ const PengaduanRoute = new Elysia({
|
||||
|
||||
|
||||
// --- PENGADUAN ---
|
||||
.post("/create", async ({ body }) => {
|
||||
const { judulPengaduan, detailPengaduan, lokasi, namaGambar, kategoriId, namaWarga, noTelepon } = body
|
||||
.post("/create", async ({ body, headers }) => {
|
||||
const { judulPengaduan, detailPengaduan, lokasi, namaGambar, kategoriId } = body
|
||||
const namaWarga = headers['x-user'] || ""
|
||||
const noTelepon = headers['x-phone'] || ""
|
||||
let imageFix = namaGambar
|
||||
const noPengaduan = await generateNoPengaduan()
|
||||
let idCategoryFix = kategoriId
|
||||
@@ -128,17 +130,23 @@ const PengaduanRoute = new Elysia({
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
if (!cariCategory) {
|
||||
idCategoryFix = "lainnya"
|
||||
} else {
|
||||
idCategoryFix = cariCategory.id
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
} else {
|
||||
idCategoryFix = "lainnya"
|
||||
}
|
||||
|
||||
if (!isValidPhone(noTelepon)) {
|
||||
return { success: false, message: `nomor telepon ${noTelepon} tidak valid, harap masukkan nomor yang benar` }
|
||||
}
|
||||
|
||||
const nomorHP = normalizePhoneNumber({ phone: noTelepon })
|
||||
const dataWarga = await prisma.warga.upsert({
|
||||
where: {
|
||||
@@ -212,20 +220,20 @@ const PengaduanRoute = new Elysia({
|
||||
})),
|
||||
|
||||
kategoriId: t.Optional(t.String({
|
||||
examples: ["kebersihan"],
|
||||
description: "ID atau nama kategori pengaduan (contoh: kebersihan, keamanan, lainnya)"
|
||||
examples: ["kebersihan", "infrastruktur", "keamanan"],
|
||||
description: "Nama kategori pengaduan (contoh: kebersihan, keamanan, lainnya)"
|
||||
})),
|
||||
|
||||
namaWarga: t.Optional(t.String({
|
||||
examples: ["budiman"],
|
||||
description: "Nama warga yang melapor"
|
||||
})),
|
||||
// namaWarga: t.String({
|
||||
// examples: ["budiman"],
|
||||
// description: "Nama warga yang melapor"
|
||||
// }),
|
||||
|
||||
noTelepon: t.String({
|
||||
error: "Nomor telepon harus diisi",
|
||||
examples: ["08123456789", "+628123456789"],
|
||||
description: "Nomor telepon warga pelapor"
|
||||
}),
|
||||
// noTelepon: t.String({
|
||||
// error: "Nomor telepon harus diisi",
|
||||
// examples: ["08123456789", "+628123456789"],
|
||||
// description: "Nomor telepon warga pelapor"
|
||||
// }),
|
||||
}),
|
||||
|
||||
detail: {
|
||||
@@ -285,6 +293,84 @@ const PengaduanRoute = new Elysia({
|
||||
description: `tool untuk update status pengaduan`
|
||||
}
|
||||
})
|
||||
.post("/update", async ({ body }) => {
|
||||
const { noPengaduan, judul, detail, lokasi, namaGambar } = body
|
||||
let dataUpdate = {}
|
||||
|
||||
const cek = await prisma.pengaduan.findFirst({
|
||||
where: {
|
||||
noPengaduan,
|
||||
},
|
||||
select: {
|
||||
id: true
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
if (!cek) {
|
||||
return { success: false, message: 'gagal update status pengaduan, nomer ' + noPengaduan + ' tidak ditemukan' }
|
||||
}
|
||||
|
||||
if (judul) {
|
||||
dataUpdate = { title: judul }
|
||||
}
|
||||
|
||||
if (detail) {
|
||||
dataUpdate = { ...dataUpdate, detail }
|
||||
}
|
||||
|
||||
if (lokasi) {
|
||||
dataUpdate = { ...dataUpdate, location: lokasi }
|
||||
}
|
||||
|
||||
if (namaGambar) {
|
||||
dataUpdate = { ...dataUpdate, image: namaGambar }
|
||||
}
|
||||
|
||||
const pengaduan = await prisma.pengaduan.updateMany({
|
||||
where: {
|
||||
noPengaduan
|
||||
},
|
||||
data: dataUpdate
|
||||
})
|
||||
|
||||
const keys = Object.keys(dataUpdate).join(", ");
|
||||
|
||||
await prisma.historyPengaduan.create({
|
||||
data: {
|
||||
idPengaduan: cek.id,
|
||||
deskripsi: `Pengaduan diupdate oleh warga (data yg diupdate: ${keys})`,
|
||||
}
|
||||
})
|
||||
|
||||
return { success: true, message: 'pengaduan dengan nomer ' + noPengaduan + ' sudah diupdate' }
|
||||
}, {
|
||||
body: t.Object({
|
||||
noPengaduan: t.String({
|
||||
error: "nomer pengaduan harus diisi",
|
||||
description: "Nomer pengaduan yang ingin diupdate"
|
||||
}),
|
||||
judul: t.Optional(t.String({
|
||||
error: "judul harus diisi",
|
||||
description: "Judul pengaduan yang ingin diupdate"
|
||||
})),
|
||||
detail: t.Optional(t.String({
|
||||
description: "detail pengaduan yang ingin diupdate"
|
||||
})),
|
||||
lokasi: t.Optional(t.String({
|
||||
description: "lokasi pengaduan yang ingin diupdate"
|
||||
})),
|
||||
namaGambar: t.Optional(t.String({
|
||||
description: "Nama file gambar yang telah diupload untuk update data pengaduan"
|
||||
})),
|
||||
}),
|
||||
|
||||
detail: {
|
||||
summary: "Update Data Pengaduan",
|
||||
description: `tool untuk update data pengaduan`,
|
||||
tags: ["mcp"]
|
||||
}
|
||||
})
|
||||
.get("/detail", async ({ query }) => {
|
||||
const { id } = query
|
||||
|
||||
@@ -353,14 +439,7 @@ const PengaduanRoute = new Elysia({
|
||||
const dataHistoryFix = dataHistory.map((item: any) => ({
|
||||
..._.omit(item, ["User", "createdAt"]),
|
||||
nameUser: item.User?.name,
|
||||
createdAt: item.createdAt.toLocaleString("id-ID", {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
hour12: false
|
||||
}),
|
||||
createdAt: item.createdAt
|
||||
}))
|
||||
|
||||
|
||||
@@ -399,43 +478,44 @@ const PengaduanRoute = new Elysia({
|
||||
tags: ["mcp"]
|
||||
}
|
||||
})
|
||||
.get("/", async ({ query }) => {
|
||||
const { take, page, search, phone } = query
|
||||
const skip = !page ? 0 : (Number(page) - 1) * (!take ? 10 : Number(take))
|
||||
.get("/", async ({ query, headers }) => {
|
||||
// const { take, page, search } = query
|
||||
const phone = headers['x-phone'] || ""
|
||||
// const skip = !page ? 0 : (Number(page) - 1) * (!take ? 10 : Number(take))
|
||||
|
||||
const data = await prisma.pengaduan.findMany({
|
||||
skip,
|
||||
take: !take ? 10 : Number(take),
|
||||
// skip,
|
||||
// take: !take ? 10 : Number(take),
|
||||
orderBy: {
|
||||
createdAt: "asc"
|
||||
},
|
||||
where: {
|
||||
isActive: true,
|
||||
OR: [
|
||||
{
|
||||
title: {
|
||||
contains: search ?? "",
|
||||
mode: "insensitive"
|
||||
},
|
||||
},
|
||||
{
|
||||
noPengaduan: {
|
||||
contains: search ?? "",
|
||||
mode: "insensitive"
|
||||
},
|
||||
},
|
||||
{
|
||||
detail: {
|
||||
contains: search ?? "",
|
||||
mode: "insensitive"
|
||||
},
|
||||
}
|
||||
],
|
||||
AND: {
|
||||
Warga: {
|
||||
phone: phone
|
||||
}
|
||||
}
|
||||
// OR: [
|
||||
// {
|
||||
// title: {
|
||||
// contains: search ?? "",
|
||||
// mode: "insensitive"
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// noPengaduan: {
|
||||
// contains: search ?? "",
|
||||
// mode: "insensitive"
|
||||
// },
|
||||
// },
|
||||
// {
|
||||
// detail: {
|
||||
// contains: search ?? "",
|
||||
// mode: "insensitive"
|
||||
// },
|
||||
// }
|
||||
// ],
|
||||
// AND: {
|
||||
// Warga: {
|
||||
// phone: phone
|
||||
// }
|
||||
// }
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
@@ -470,12 +550,11 @@ const PengaduanRoute = new Elysia({
|
||||
|
||||
return dataFix
|
||||
}, {
|
||||
query: t.Object({
|
||||
take: t.String({ optional: true }),
|
||||
page: t.String({ optional: true }),
|
||||
search: t.String({ optional: true }),
|
||||
phone: t.String({ minLength: 11, error: "phone harus diisi" }),
|
||||
}),
|
||||
// query: t.Object({
|
||||
// take: t.String({ optional: true }),
|
||||
// page: t.String({ optional: true }),
|
||||
// search: t.String({ optional: true }),
|
||||
// }),
|
||||
detail: {
|
||||
summary: "List Pengaduan Warga By Phone",
|
||||
description: `tool untuk mendapatkan list pengaduan warga by phone`,
|
||||
@@ -516,14 +595,59 @@ const PengaduanRoute = new Elysia({
|
||||
detail: {
|
||||
summary: "Upload File (FormData)",
|
||||
description: "Tool untuk upload file ke folder tujuan dengan memakai FormData",
|
||||
tags: ["mcp"],
|
||||
// tags: ["mcp"],
|
||||
consumes: ["multipart/form-data"]
|
||||
},
|
||||
})
|
||||
.post("/upload-file-form-data", async ({ body }) => {
|
||||
const { file } = body;
|
||||
|
||||
// // Validasi file
|
||||
// if (!file) {
|
||||
// return { success: false, message: "File tidak ditemukan" };
|
||||
// }
|
||||
|
||||
// // Rename file
|
||||
// const renamedFile = renameFile({ oldFile: file, newName: 'random' });
|
||||
|
||||
|
||||
// // Upload ke Seafile (pastikan uploadFile menerima Blob atau ArrayBuffer)
|
||||
// // const buffer = await file.arrayBuffer();
|
||||
// const result = await uploadFile(defaultConfigSF, renamedFile, 'pengaduan');
|
||||
// if (result == 'gagal') {
|
||||
// return { success: false, message: "Upload gagal" };
|
||||
// }
|
||||
|
||||
return {
|
||||
success: true,
|
||||
file: JSON.stringify(file),
|
||||
fileInfo: {
|
||||
name: file.name || 'kosong',
|
||||
size: file.size || 0,
|
||||
type: file.type || 'kosong'
|
||||
}
|
||||
// message: "Upload berhasil",
|
||||
// filename: renamedFile.name,
|
||||
// size: renamedFile.size,
|
||||
// seafileResult: result
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
file: t.Any(),
|
||||
// folder: t.String(),
|
||||
}),
|
||||
detail: {
|
||||
summary: "Upload File (FormData)",
|
||||
description: "Tool untuk upload file ke folder tujuan dengan memakai FormData",
|
||||
// tags: ["mcp"],
|
||||
consumes: ["multipart/form-data"]
|
||||
},
|
||||
})
|
||||
.post("/upload-base64", async ({ body }) => {
|
||||
const { data, mimetype } = body;
|
||||
const { data, mimetype, kategori } = body;
|
||||
const ext = mimeToExtension(mimetype)
|
||||
const name = `${uuidv4()}.${ext}`
|
||||
const kategoriFix = kategori === 'pengaduan' ? 'pengaduan' : 'syarat-dokumen';
|
||||
|
||||
// Validasi file
|
||||
if (!data) {
|
||||
@@ -535,7 +659,8 @@ const PengaduanRoute = new Elysia({
|
||||
// const base64String = Buffer.from(buffer).toString("base64");
|
||||
|
||||
// (Opsional) jika perlu dikirim ke Seafile sebagai base64
|
||||
const result = await uploadFileBase64(defaultConfigSF, { name: name, data: data });
|
||||
// const result = await uploadFileBase64(defaultConfigSF, { name: name, data: data });
|
||||
const result = await uploadFileToFolder(defaultConfigSF, { name: name, data: data }, kategoriFix);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -544,17 +669,19 @@ const PengaduanRoute = new Elysia({
|
||||
name,
|
||||
mimetype,
|
||||
ext,
|
||||
kategori,
|
||||
}
|
||||
};
|
||||
}, {
|
||||
body: t.Object({
|
||||
data: t.String(),
|
||||
mimetype: t.String()
|
||||
mimetype: t.String(),
|
||||
kategori: t.String()
|
||||
}),
|
||||
detail: {
|
||||
summary: "Upload File (Base64)",
|
||||
description: "Tool untuk upload file ke Seafile dalam format Base64",
|
||||
tags: ["mcp"],
|
||||
// tags: ["mcp"],
|
||||
consumes: ["multipart/form-data"]
|
||||
},
|
||||
})
|
||||
@@ -601,6 +728,10 @@ const PengaduanRoute = new Elysia({
|
||||
}
|
||||
}
|
||||
|
||||
const totalData = await prisma.pengaduan.count({
|
||||
where
|
||||
});
|
||||
|
||||
const data = await prisma.pengaduan.findMany({
|
||||
skip,
|
||||
take: !take ? 10 : Number(take),
|
||||
@@ -638,12 +769,20 @@ const PengaduanRoute = new Elysia({
|
||||
detail: item.detail,
|
||||
status: item.status,
|
||||
location: item.location,
|
||||
createdAt: item.createdAt.toLocaleDateString("id-ID", { day: "numeric", month: "long", year: "numeric" }),
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
updatedAt: 'terakhir diperbarui ' + getLastUpdated(item.updatedAt),
|
||||
}
|
||||
})
|
||||
|
||||
return dataFix
|
||||
const dataReturn = {
|
||||
data: dataFix,
|
||||
total: totalData,
|
||||
page: Number(page) || 1,
|
||||
pageSize: !take ? 10 : Number(take),
|
||||
totalPages: Math.ceil(totalData / (!take ? 10 : Number(take)))
|
||||
}
|
||||
|
||||
return dataReturn
|
||||
}, {
|
||||
query: t.Object({
|
||||
take: t.String({ optional: true }),
|
||||
@@ -745,8 +884,6 @@ const PengaduanRoute = new Elysia({
|
||||
description: "Tool untuk delete file Seafile",
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
;
|
||||
|
||||
export default PengaduanRoute
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -9,9 +9,31 @@ const WargaRoute = new Elysia({
|
||||
})
|
||||
|
||||
.get("/list", async ({ query }) => {
|
||||
const { search } = query
|
||||
const { search, page = 1 } = query
|
||||
const dataSkip = page == null || page == undefined ? 0 : Number(page) * 10 - 10;
|
||||
|
||||
const totalData = await prisma.warga.count({
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
name: {
|
||||
contains: search,
|
||||
mode: "insensitive"
|
||||
}
|
||||
},
|
||||
{
|
||||
phone: {
|
||||
contains: search,
|
||||
mode: "insensitive"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
});
|
||||
|
||||
const data = await prisma.warga.findMany({
|
||||
skip: dataSkip,
|
||||
take: 10,
|
||||
where: {
|
||||
OR: [
|
||||
{
|
||||
@@ -33,7 +55,15 @@ const WargaRoute = new Elysia({
|
||||
}
|
||||
})
|
||||
|
||||
return data
|
||||
const dataFix = {
|
||||
data,
|
||||
total: totalData,
|
||||
page: Number(page) || 1,
|
||||
pageSize: 10,
|
||||
totalPages: Math.ceil(totalData / 10)
|
||||
};
|
||||
|
||||
return dataFix
|
||||
}, {
|
||||
detail: {
|
||||
summary: "List Warga",
|
||||
|
||||
Reference in New Issue
Block a user