update: dashboard admin #41
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=="],
|
||||
|
||||
@@ -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={(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>
|
||||
);
|
||||
}
|
||||
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, Title } from "@mantine/core";
|
||||
import { IconSettings } 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" } // warna legend putih
|
||||
},
|
||||
tooltip: {},
|
||||
dataset: {
|
||||
dimensions: data.dimensions,
|
||||
source: data.source
|
||||
},
|
||||
xAxis: {
|
||||
type: "category",
|
||||
axisLabel: { color: "#fff" }
|
||||
},
|
||||
yAxis: {
|
||||
type: "value",
|
||||
minInterval: 1
|
||||
},
|
||||
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="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" />
|
||||
<Stack gap="sm">
|
||||
<EChartsReact style={{ height: 400, width: "100%" }} option={options} />
|
||||
{/* <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>
|
||||
)
|
||||
}
|
||||
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.noPengaduan}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
276
src/server/routes/dashboard_route.ts
Normal file
276
src/server/routes/dashboard_route.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
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
|
||||
? 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
|
||||
? 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,
|
||||
updatedAt: true,
|
||||
CategoryPelayanan: {
|
||||
select: {
|
||||
name: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const dataPelayananFix = dataPelayanan.map((item) => {
|
||||
return {
|
||||
id: item.id,
|
||||
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
|
||||
Reference in New Issue
Block a user