Merge pull request 'amalia/24-nov-25' (#35) from amalia/24-nov-25 into main

Reviewed-on: http://wibugit.wibudev.com/wibu/jenna-mcp/pulls/35
This commit is contained in:
2025-11-24 17:41:22 +08:00
20 changed files with 1294 additions and 104 deletions

View File

@@ -9,11 +9,13 @@ datasource db {
}
model Role {
id String @id @default(cuid())
name String
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
User User[]
id String @id @default(cuid())
name String
permissions Json?
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
User User[]
}
model User {

View File

@@ -1,5 +1,6 @@
import { categoryPelayananSurat } from "@/lib/categoryPelayananSurat";
import { confDesa } from "@/lib/configurationDesa";
import permissionConfig from "@/lib/listPermission.json"; // JSON yang kita buat
import { prisma } from "@/server/lib/prisma";
const category = [
@@ -29,14 +30,6 @@ const role = [
{
id: "developer",
name: "developer"
},
{
id: "admin",
name: "admin"
},
{
id: "pelaksana",
name: "pelaksana"
}
]
@@ -51,11 +44,30 @@ const user = [
];
(async () => {
const allKeys: string[] = [];
function collectKeys(items: any[]) {
items.forEach((item) => {
allKeys.push(item.key);
if (item.children) collectKeys(item.children);
});
}
collectKeys(permissionConfig.menus);
for (const r of role) {
await prisma.role.upsert({
where: { id: r.id },
create: r,
update: r
create: {
id: r.id,
name: r.name,
permissions: allKeys as any,
},
update: {
name: r.name,
permissions: allKeys as any,
}
})
console.log(`✅ Role ${r.name} seeded successfully`)

View File

@@ -16,13 +16,14 @@ import {
} from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import { IconEdit } from "@tabler/icons-react";
import type { JsonValue } from "generated/prisma/runtime/library";
import _ from "lodash";
import { useState } from "react";
import useSWR from "swr";
import ModalFile from "./ModalFile";
import notification from "./notificationGlobal";
export default function DesaSetting() {
export default function DesaSetting({ permissions }: { permissions: JsonValue[] }) {
const [btnDisable, setBtnDisable] = useState(false);
const [btnLoading, setBtnLoading] = useState(false);
const [opened, { open, close }] = useDisclosure(false);
@@ -213,12 +214,13 @@ export default function DesaSetting() {
}
</Table.Td>
<Table.Td>
<Tooltip label="Edit Setting">
<Tooltip label={permissions.includes("setting.desa.edit") ? "Edit Setting" : "Edit Setting - Anda tidak memiliki akses"}>
<ActionIcon
variant="light"
size="sm"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => chooseEdit({ data: v })}
disabled={!permissions.includes("setting.desa.edit")}
>
<IconEdit size={20} />
</ActionIcon>

View File

@@ -18,11 +18,12 @@ import {
} from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import { IconEdit, IconEye, IconPlus, IconTrash } from "@tabler/icons-react";
import type { JsonValue } from "generated/prisma/runtime/library";
import { useState } from "react";
import useSWR from "swr";
import notification from "./notificationGlobal";
export default function KategoriPelayananSurat() {
export default function KategoriPelayananSurat({ permissions }: { permissions: JsonValue[] }) {
const [openedDelete, { open: openDelete, close: closeDelete }] =
useDisclosure(false);
const [openedDetail, { open: openDetail, close: closeDetail }] =
@@ -52,6 +53,7 @@ export default function KategoriPelayananSurat() {
mutate();
}, []);
async function handleCreate() {
try {
setBtnLoading(true);
@@ -533,15 +535,19 @@ export default function KategoriPelayananSurat() {
<Title order={4} c="gray.2">
Kategori Pelayanan Surat
</Title>
<Tooltip label="Tambah Kategori Pelayanan Surat">
<Button
variant="light"
leftSection={<IconPlus size={20} />}
onClick={openTambah}
>
Tambah
</Button>
</Tooltip>
{
permissions.includes("setting.kategori_pelayanan.tambah") && (
<Tooltip label="Tambah Kategori Pelayanan Surat">
<Button
variant="light"
leftSection={<IconPlus size={20} />}
onClick={openTambah}
>
Tambah
</Button>
</Tooltip>
)
}
</Flex>
<Divider my={0} />
<Stack gap={"md"}>
@@ -572,7 +578,7 @@ export default function KategoriPelayananSurat() {
<IconEye size={20} />
</ActionIcon>
</Tooltip>
<Tooltip label="Edit Kategori">
<Tooltip label={permissions.includes("setting.kategori_pelayanan.edit") ? "Edit Kategori" : "Edit Kategori - Anda tidak memiliki akses"}>
<ActionIcon
variant="light"
size="sm"
@@ -581,11 +587,12 @@ export default function KategoriPelayananSurat() {
setDataChoose(v);
open();
}}
disabled={!permissions.includes("setting.kategori_pelayanan.edit")}
>
<IconEdit size={20} />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete Kategori">
<Tooltip label={permissions.includes("setting.kategori_pelayanan.delete") ? "Hapus Kategori" : "Hapus Kategori - Anda tidak memiliki akses"}>
<ActionIcon
variant="light"
size="sm"
@@ -595,6 +602,7 @@ export default function KategoriPelayananSurat() {
setDataDelete(v.id);
openDelete();
}}
disabled={!permissions.includes("setting.kategori_pelayanan.delete")}
>
<IconTrash size={20} />
</ActionIcon>

View File

@@ -15,11 +15,12 @@ import {
} from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import { IconEdit, IconPlus, IconTrash } from "@tabler/icons-react";
import type { JsonValue } from "generated/prisma/runtime/library";
import { useState } from "react";
import useSWR from "swr";
import notification from "./notificationGlobal";
export default function KategoriPengaduan() {
export default function KategoriPengaduan({ permissions }: { permissions: JsonValue[] }) {
const [openedDelete, { open: openDelete, close: closeDelete }] =
useDisclosure(false);
const [btnDisable, setBtnDisable] = useState(true);
@@ -293,15 +294,19 @@ export default function KategoriPengaduan() {
<Title order={4} c="gray.2">
Kategori Pengaduan
</Title>
<Tooltip label="Tambah Kategori Pengaduan">
<Button
variant="light"
leftSection={<IconPlus size={20} />}
onClick={openTambah}
>
Tambah
</Button>
</Tooltip>
{
permissions.includes("setting.kategori_pengaduan.tambah") && (
<Tooltip label="Tambah Kategori Pengaduan">
<Button
variant="light"
leftSection={<IconPlus size={20} />}
onClick={openTambah}
>
Tambah
</Button>
</Tooltip>
)
}
</Flex>
<Divider my={0} />
<Stack gap={"md"}>
@@ -318,17 +323,18 @@ export default function KategoriPengaduan() {
<Table.Td>{v.name}</Table.Td>
<Table.Td>
<Group>
<Tooltip label="Edit Kategori">
<Tooltip label={permissions.includes("setting.kategori_pengaduan.edit") ? "Edit Kategori" : "Edit Kategori - Anda tidak memiliki akses"}>
<ActionIcon
variant="light"
size="sm"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => chooseEdit({ data: v })}
disabled={!permissions.includes("setting.kategori_pengaduan.edit")}
>
<IconEdit size={20} />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete Kategori">
<Tooltip label={permissions.includes("setting.kategori_pengaduan.delete") ? "Hapus Kategori" : "Hapus Kategori - Anda tidak memiliki akses"}>
<ActionIcon
variant="light"
size="sm"
@@ -338,6 +344,7 @@ export default function KategoriPengaduan() {
setDataDelete(v.id);
openDelete();
}}
disabled={!permissions.includes("setting.kategori_pengaduan.delete")}
>
<IconTrash size={20} />
</ActionIcon>

View File

@@ -64,7 +64,7 @@ export default function ModalFile({ open, onClose, folder, fileName }: { open: b
{viewFile && (
<>
{typeFile == "pdf" ? (
<embed src={viewFile} type="application/pdf" width="100%" height="100%" />
<embed src={viewFile} type="application/pdf" width="100%" height="950" />
) : (
<Image
radius="md"

View File

@@ -0,0 +1,57 @@
import { groupPermissions } from "@/lib/groupPermission";
import { Button, Stack, Text } from "@mantine/core";
import { useState } from "react";
interface Node {
label: string;
children: any;
actions: string[];
}
function RenderNode({ node }: { node: Node }) {
const sub = Object.values(node.children || {});
return (
<Stack pl="md" gap={6}>
{/* Title */}
<Text fw={600}>- {node.label}</Text>
{/* Children */}
{sub.map((child: any, i) => (
<RenderNode key={i} node={child} />
))}
</Stack>
);
}
export default function PermissionRole({ permissions }: { permissions: string[] }) {
const [showAll, setShowAll] = useState(false);
if (!permissions?.length) return <Text c="dimmed">-</Text>;
const groups = groupPermissions(permissions);
const rootNodes = Object.values(groups);
return (
<Stack gap="lg">
{
showAll ?
rootNodes.map((node: any, idx) => (
<RenderNode key={idx} node={node} />
))
:
rootNodes.slice(0, 2).map((node: any, idx) => (
<RenderNode key={idx} node={node} />
))
}
<Button
variant="subtle"
size="xs"
onClick={() => setShowAll(!showAll)}
w="fit-content"
ml="md"
>
{showAll ? "View less" : "View more"}
</Button>
</Stack>
);
}

View File

@@ -0,0 +1,144 @@
import permissionConfig from "@/lib/listPermission.json";
import { ActionIcon, Checkbox, Collapse, Group, Stack, Text } from "@mantine/core";
import { IconChevronDown, IconChevronRight } from "@tabler/icons-react";
import { useState } from "react";
interface Node {
label: string;
key: string;
children?: Node[];
}
export default function PermissionTree({
selected,
onChange,
}: {
selected: string[];
onChange: (val: string[]) => void;
}) {
const [open, setOpen] = useState<Record<string, boolean>>({});
const toggle = (key: string) => {
setOpen((prev) => ({ ...prev, [key]: !prev[key] }));
};
// Ambil semua key dari node termasuk semua keturunannya
const collectKeys = (n: Node): string[] => {
if (!n.children) return [n.key];
return [n.key, ...n.children.flatMap(collectKeys)];
};
const checkState = (node: Node): { all: boolean; some: boolean } => {
const children = node.children || [];
// Jika tidak ada anak → nilai hanya berdasarkan dirinya sendiri
if (children.length === 0) {
const checked = selected.includes(node.key);
return { all: checked, some: checked };
}
// Rekursif ke anak
let all = selected.includes(node.key);
let some = selected.includes(node.key);
for (const c of children) {
const childState = checkState(c);
if (!childState.all) all = false;
if (childState.some) some = true;
}
return { all, some };
};
// Untuk ordering sesuai urutan JSON
const getOrderedKeys = (nodes: Node[]): string[] =>
nodes.flatMap((n) => [n.key, ...getOrderedKeys(n.children || [])]);
const RenderNode = ({ node }: { node: Node }) => {
const children = node.children || [];
const state = checkState(node); // ← gunakan recursive evaluator
const isChecked = state.all;
const isIndeterminate = !state.all && state.some;
const showChildren = open[node.key] ?? false;
// Ambil semua key anak + parent
const collectKeys = (n: Node): string[] => {
if (!n.children) return [n.key];
return [n.key, ...n.children.flatMap(collectKeys)];
};
const allKeys = collectKeys(node);
const toggleCheck = (checked: boolean) => {
let updated = new Set(selected);
if (checked) {
// parent + semua child
allKeys.forEach((k) => updated.add(k));
} else {
// hilangkan parent + semua child
allKeys.forEach((k) => updated.delete(k));
}
// ⬇⬇⬇ PERBAIKAN PENTING ⬇⬇⬇
//
// Jika node indeterminate → parent harus tetap ada di selected
//
if (isIndeterminate) {
updated.add(node.key);
}
// Jika semua child tercentang → parent harus checked
if (isChecked) {
updated.add(node.key);
}
onChange([...updated]);
};
return (
<Stack gap={4} pl="xs">
<Group wrap="nowrap">
{children.length > 0 ? (
<ActionIcon variant="subtle" onClick={() => toggle(node.key)}>
{showChildren ? <IconChevronDown size={16} /> : <IconChevronRight size={16} />}
</ActionIcon>
) : (
<div style={{ width: 24 }} />
)}
<Checkbox
label={node.label}
checked={isChecked}
indeterminate={isIndeterminate}
onChange={(e) => toggleCheck(e.target.checked)}
/>
</Group>
{children.length > 0 && (
<Collapse in={showChildren}>
<Stack gap={4} pl="md">
{children.map((c) => (
<RenderNode key={c.key} node={c} />
))}
</Stack>
</Collapse>
)}
</Stack>
);
};
return (
<Stack>
<Text size="sm">Hak Akses</Text>
{permissionConfig.menus.map((menu: Node) => (
<RenderNode key={menu.key} node={menu} />
))}
</Stack>
);
}

View File

@@ -9,10 +9,11 @@ import {
Stack,
Title,
} from "@mantine/core";
import type { JsonValue } from "generated/prisma/runtime/library";
import { useEffect, useState } from "react";
import notification from "./notificationGlobal";
export default function ProfileUser() {
export default function ProfileUser({ permissions }: { permissions: JsonValue[] }) {
const [opened, setOpened] = useState(false);
const [openedPassword, setOpenedPassword] = useState(false);
const [pwdBaru, setPwdBaru] = useState("");
@@ -126,12 +127,21 @@ export default function ProfileUser() {
Profile Pengguna
</Title>
<Group gap="md">
<Button variant="light" onClick={() => setOpened(true)}>
Edit
</Button>
<Button variant="light" onClick={() => setOpenedPassword(true)}>
Ubah Password
</Button>
{
permissions.includes("setting.profile.edit") && (
<Button variant="light" onClick={() => setOpened(true)}>
Edit
</Button>
)
}
{
permissions.includes("setting.profile.password") && (
<Button variant="light" onClick={() => setOpenedPassword(true)}>
Ubah Password
</Button>
)
}
</Group>
</Flex>
<Divider my={0} />

View File

@@ -0,0 +1,394 @@
import apiFetch from "@/lib/apiFetch";
import {
ActionIcon,
Button,
Divider,
Flex,
Group,
Input,
Modal,
Stack,
Table,
Text,
Title,
Tooltip
} from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import { IconEdit, IconPlus, IconTrash } from "@tabler/icons-react";
import type { JsonValue } from "generated/prisma/runtime/library";
import { useState } from "react";
import useSWR from "swr";
import notification from "./notificationGlobal";
import PermissionRole from "./PermissionRole";
import PermissionTree from "./PermissionTree";
export default function UserRoleSetting({ permissions }: { permissions: JsonValue[] }) {
const [btnDisable, setBtnDisable] = useState(true);
const [btnLoading, setBtnLoading] = useState(false);
const [opened, { open, close }] = useDisclosure(false);
const [openedDelete, { open: openDelete, close: closeDelete }] =
useDisclosure(false);
const [dataDelete, setDataDelete] = useState("");
const {
data: dataRole,
mutate: mutateRole,
isLoading: isLoadingRole,
} = useSWR("user-role", () => apiFetch.api.user.role.get());
const [openedTambah, { open: openTambah, close: closeTambah }] =
useDisclosure(false);
const { data, mutate, isLoading } = useSWR("role-list", () =>
apiFetch.api.user.role.get(),
);
const list = data?.data || [];
const listRole = dataRole?.data || [];
const [dataEdit, setDataEdit] = useState({
id: "",
name: "",
permissions: [],
});
const [dataTambah, setDataTambah] = useState({
name: "",
permissions: [],
});
const [error, setError] = useState({
name: false,
permissions: false,
});
useShallowEffect(() => {
mutate();
}, []);
async function handleCreate() {
try {
setBtnLoading(true);
const res = await apiFetch.api.user["role-create"].post(dataTambah as any);
if (res.status === 200) {
mutate();
closeTambah();
setDataTambah({
name: "",
permissions: [],
});
notification({
title: "Success",
message: "Your user have been saved",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to create user ",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to create user",
type: "error",
});
} finally {
setBtnLoading(false);
}
}
async function handleEdit() {
try {
setBtnLoading(true);
const res = await apiFetch.api.pengaduan.category.update.post(dataEdit);
if (res.status === 200) {
mutate();
close();
notification({
title: "Success",
message: "Your category have been saved",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to edit category",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to edit category",
type: "error",
});
} finally {
setBtnLoading(false);
}
}
async function handleDelete() {
try {
setBtnLoading(true);
const res = await apiFetch.api.user["role-delete"].post({ id: dataDelete });
if (res.status === 200) {
mutate();
closeDelete();
notification({
title: "Success",
message: "Your role have been deleted",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to delete role",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to delete role",
type: "error",
});
} finally {
setBtnLoading(false);
}
}
function chooseEdit({
data,
}: {
data: {
id: string;
name: string;
permissions: [];
};
}) {
setDataEdit(data);
open();
}
function onValidation({ kat, value, aksi, }: { kat: "name" | "permission"; value: string | null; aksi: "edit" | "tambah"; }) {
if (value == null || value.length < 1) {
setBtnDisable(true);
setError({ ...error, [kat]: true });
} else {
setBtnDisable(false);
setError({ ...error, [kat]: false });
}
if (aksi === "edit") {
setDataEdit({ ...dataEdit, [kat]: value });
} else {
setDataTambah({ ...dataTambah, [kat]: value });
}
}
console.log("dataTambah", dataTambah);
useShallowEffect(() => {
if (dataEdit.name.length > 0) {
setBtnDisable(false);
}
}, [dataEdit.id]);
return (
<>
{/* Modal Edit */}
<Modal
opened={opened}
onClose={close}
title={"Edit"}
centered
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="ld">
<Input.Wrapper label="Edit Kategori">
<Input
value={dataEdit.name}
onChange={(e) =>
onValidation({
kat: "name",
value: e.target.value,
aksi: "edit",
})
}
/>
</Input.Wrapper>
<Group justify="center" grow>
<Button variant="light" onClick={close}>
Batal
</Button>
<Button
variant="filled"
onClick={handleEdit}
disabled={btnDisable}
loading={btnLoading}
>
Simpan
</Button>
</Group>
</Stack>
</Modal>
{/* Modal Tambah */}
<Modal
opened={openedTambah}
onClose={closeTambah}
title={"Tambah"}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="ld">
<Input.Wrapper
label="Nama"
description=""
error={error.name ? "Field is required" : ""}
>
<Input
value={dataTambah.name}
onChange={(e) =>
onValidation({
kat: "name",
value: e.target.value,
aksi: "tambah",
})
}
/>
</Input.Wrapper>
<PermissionTree
selected={dataTambah.permissions}
onChange={(permissions) => {
setDataTambah({ ...dataTambah, permissions: permissions as never[] });
}}
/>
<Group justify="center" grow>
<Button variant="light" onClick={closeTambah}>
Batal
</Button>
<Button
variant="filled"
onClick={handleCreate}
disabled={
btnDisable ||
dataTambah.name.length < 1 ||
dataTambah.permissions.length < 1
}
loading={btnLoading}
>
Simpan
</Button>
</Group>
</Stack>
</Modal>
{/* Modal Delete */}
<Modal
opened={openedDelete}
onClose={closeDelete}
title={"Delete"}
overlayProps={{ backgroundOpacity: 0.55, blur: 3 }}
>
<Stack gap="md">
<Text size="md" color="gray.6">
Apakah anda yakin ingin menghapus role ini?
</Text>
<Group justify="center" grow>
<Button variant="light" onClick={closeDelete}>
Batal
</Button>
<Button
variant="filled"
color="red"
onClick={handleDelete}
loading={btnLoading}
>
Hapus
</Button>
</Group>
</Stack>
</Modal>
<Stack gap={"md"}>
<Flex align="center" justify="space-between">
<Title order={4} c="gray.2">
Daftar Role
</Title>
{
permissions.includes('setting.user_role.tambah') && (
<Tooltip label="Tambah Role">
<Button
variant="light"
leftSection={<IconPlus size={20} />}
onClick={openTambah}
>
Tambah
</Button>
</Tooltip>
)
}
</Flex>
<Divider my={0} />
<Stack gap={"md"}>
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Role</Table.Th>
<Table.Th>Permission</Table.Th>
<Table.Th>Aksi</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{list.length > 0 ? (
list?.map((v: any) => (
<Table.Tr key={v.id}>
<Table.Td>{v.name}</Table.Td>
<Table.Td>
<PermissionRole permissions={v.permissions} />
</Table.Td>
<Table.Td>
<Group>
<Tooltip label={permissions.includes('setting.user_role.edit') ? "Edit Role" : "Edit Role - Anda tidak memiliki akses"}>
<ActionIcon
variant="light"
size="sm"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => chooseEdit({ data: v })}
disabled={!permissions.includes('setting.user_role.edit')}
>
<IconEdit size={20} />
</ActionIcon>
</Tooltip>
<Tooltip label={permissions.includes('setting.user_role.delete') ? "Delete Role" : "Delete Role - Anda tidak memiliki akses"}>
<ActionIcon
variant="light"
size="sm"
color="red"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => {
setDataDelete(v.id);
openDelete();
}}
disabled={!permissions.includes('setting.user_role.delete')}
>
<IconTrash size={20} />
</ActionIcon>
</Tooltip>
</Group>
</Table.Td>
</Table.Tr>
))
) : (
<Table.Tr>
<Table.Td colSpan={5} align="center">
Data Role Tidak Ditemukan
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Stack>
</Stack>
</>
);
}

View File

@@ -16,11 +16,12 @@ import {
} from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import { IconEdit, IconPlus, IconTrash } from "@tabler/icons-react";
import type { JsonValue } from "generated/prisma/runtime/library";
import { useState } from "react";
import useSWR from "swr";
import notification from "./notificationGlobal";
export default function UserSetting() {
export default function UserSetting({ permissions }: { permissions: JsonValue[] }) {
const [btnDisable, setBtnDisable] = useState(true);
const [btnLoading, setBtnLoading] = useState(false);
const [opened, { open, close }] = useDisclosure(false);
@@ -390,15 +391,20 @@ export default function UserSetting() {
<Title order={4} c="gray.2">
Daftar User
</Title>
<Tooltip label="Tambah User">
<Button
variant="light"
leftSection={<IconPlus size={20} />}
onClick={openTambah}
>
Tambah
</Button>
</Tooltip>
{
permissions.includes('setting.user.tambah') && (
<Tooltip label="Tambah User">
<Button
variant="light"
leftSection={<IconPlus size={20} />}
onClick={openTambah}
>
Tambah
</Button>
</Tooltip>
)
}
</Flex>
<Divider my={0} />
<Stack gap={"md"}>
@@ -422,17 +428,18 @@ export default function UserSetting() {
<Table.Td>{v.roleId}</Table.Td>
<Table.Td>
<Group>
<Tooltip label="Edit User">
<Tooltip label={permissions.includes('setting.user.edit') ? "Edit User" : "Edit User - Anda tidak memiliki akses"}>
<ActionIcon
variant="light"
size="sm"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => chooseEdit({ data: v })}
disabled={!permissions.includes('setting.user.edit')}
>
<IconEdit size={20} />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete User">
<Tooltip label={permissions.includes('setting.user.delete') ? "Delete User" : "Delete User - Anda tidak memiliki akses"}>
<ActionIcon
variant="light"
size="sm"
@@ -442,6 +449,7 @@ export default function UserSetting() {
setDataDelete(v.id);
openDelete();
}}
disabled={!permissions.includes('setting.user.delete')}
>
<IconTrash size={20} />
</ActionIcon>

View File

@@ -0,0 +1,59 @@
import config from "@/lib/listPermission.json";
export interface PermissionNode {
key: string;
label: string;
children?: PermissionNode[];
}
interface Grouped {
[key: string]: {
label: string;
children: Grouped;
actions: string[];
};
}
/* --- Build lookup table --- */
const permissionMap: Record<string, string[]> = {};
function walk(nodes: PermissionNode[], path: string[] = []) {
nodes.forEach((n) => {
const full = [...path, n.label];
permissionMap[n.key] = full;
if (n.children) walk(n.children, full);
});
}
walk(config.menus);
/* --- Convert keys → hierarchical grouped --- */
export function groupPermissions(keys: string[]) {
const tree: Grouped = {};
keys.forEach((key) => {
const path = permissionMap[key];
if (!path) return;
let pointer = tree;
path.forEach((label, idx) => {
if (!pointer[label]) {
pointer[label] = {
label,
children: {},
actions: []
};
}
// last item = actual permission action
if (idx === path.length - 1) {
pointer[label].actions.push(label);
}
pointer = pointer[label].children;
});
});
return tree;
}

310
src/lib/listPermission.json Normal file
View File

@@ -0,0 +1,310 @@
{
"menus": [
{
"key": "dashboard",
"label": "Dashboard",
"default": true,
"children": [
{
"key": "dashboard.view",
"label": "Melihat Dashboard",
"default": true
}
]
},
{
"key": "pengaduan",
"label": "Pengaduan",
"default": true,
"children": [
{
"key": "pengaduan.view",
"label": "Melihat List & Detail",
"default": true
},
{
"key": "pengaduan.antrian",
"label": "Antrian",
"default": true,
"children": [
{
"key": "pengaduan.antrian.tolak",
"label": "Menolak",
"default": true
},
{
"key": "pengaduan.antrian.terima",
"label": "Menerima",
"default": true
}
]
},
{
"key": "pengaduan.diterima",
"label": "Diterima",
"default": true,
"children": [
{
"key": "pengaduan.diterima.dikerjakan",
"label": "Dikerjakan",
"default": true
}
]
},
{
"key": "pengaduan.dikerjakan",
"label": "Dikerjakan",
"default": true,
"children": [
{
"key": "pengaduan.dikerjakan.selesai",
"label": "Diselesaikan",
"default": true
}
]
}
]
},
{
"key": "pelayanan",
"label": "Pelayanan",
"default": true,
"children": [
{
"key": "pelayanan.view",
"label": "Melihat List & Detail",
"default": true
},
{
"key": "pelayanan.antrian",
"label": "Antrian",
"default": true,
"children": [
{
"key": "pelayanan.antrian.tolak",
"label": "Menolak",
"default": true
},
{
"key": "pelayanan.antrian.terima",
"label": "Menerima",
"default": true
}
]
},
{
"key": "pelayanan.diterima",
"label": "Diterima",
"default": true,
"children": [
{
"key": "pelayanan.diterima.tolak",
"label": "Menolak",
"default": true
},
{
"key": "pelayanan.diterima.setujui",
"label": "Menyetujui",
"default": true
}
]
}
]
},
{
"key": "warga",
"label": "Warga",
"default": true,
"children": [
{
"key": "warga.view",
"label": "Melihat List & Detail",
"default": true
}
]
},
{
"key": "setting",
"label": "Setting",
"default": true,
"children": [
{
"key": "setting.profile",
"label": "Profile",
"default": true,
"children": [
{
"key": "setting.profile.view",
"label": "View",
"default": true
},
{
"key": "setting.profile.edit",
"label": "Edit",
"default": true
},
{
"key": "setting.profile.password",
"label": "Ubah Password",
"default": true
}
]
},
{
"key": "setting.user",
"label": "User",
"default": true,
"children": [
{
"key": "setting.user.view",
"label": "View List",
"default": true
},
{
"key": "setting.user.tambah",
"label": "Tambah",
"default": true
},
{
"key": "setting.user.edit",
"label": "Edit",
"default": true
},
{
"key": "setting.user.delete",
"label": "Delete",
"default": true
}
]
},
{
"key": "setting.user_role",
"label": "User Role",
"default": true,
"children": [
{
"key": "setting.user_role.view",
"label": "View List",
"default": true
},
{
"key": "setting.user_role.tambah",
"label": "Tambah",
"default": true
},
{
"key": "setting.user_role.edit",
"label": "Edit",
"default": true
},
{
"key": "setting.user_role.delete",
"label": "Delete",
"default": true
}
]
},
{
"key": "setting.kategori_pengaduan",
"label": "Kategori Pengaduan",
"default": true,
"children": [
{
"key": "setting.kategori_pengaduan.view",
"label": "View List",
"default": true
},
{
"key": "setting.kategori_pengaduan.tambah",
"label": "Tambah",
"default": true
},
{
"key": "setting.kategori_pengaduan.edit",
"label": "Edit",
"default": true
},
{
"key": "setting.kategori_pengaduan.delete",
"label": "Delete",
"default": true
}
]
},
{
"key": "setting.kategori_pelayanan",
"label": "Kategori Pelayanan Surat",
"default": true,
"children": [
{
"key": "setting.kategori_pelayanan.view",
"label": "View List",
"default": true
},
{
"key": "setting.kategori_pelayanan.detail",
"label": "View Detail",
"default": true
},
{
"key": "setting.kategori_pelayanan.tambah",
"label": "Tambah",
"default": true
},
{
"key": "setting.kategori_pelayanan.edit",
"label": "Edit",
"default": true
},
{
"key": "setting.kategori_pelayanan.delete",
"label": "Delete",
"default": true
}
]
},
{
"key": "setting.desa",
"label": "Desa",
"default": true,
"children": [
{
"key": "setting.desa.view",
"label": "View List",
"default": true
},
{
"key": "setting.desa.edit",
"label": "Edit",
"default": true
}
]
}
]
},
{
"key": "api_key",
"label": "API Key",
"default": true,
"children": [
{
"key": "api_key.view",
"label": "View List",
"default": true
}
]
},
{
"key": "credential",
"label": "Credential",
"default": true,
"children": [
{
"key": "credential.viewØ",
"label": "View List",
"default": true
}
]
}
]
}

View File

@@ -35,6 +35,7 @@ import {
IconUsersGroup,
} from "@tabler/icons-react";
import type { User } from "generated/prisma";
import type { JsonValue } from "generated/prisma/runtime/library";
import { useEffect, useState } from "react";
import { Outlet, useLocation, useNavigate } from "react-router-dom";
@@ -212,36 +213,54 @@ function HostView() {
function NavigationDashboard() {
const navigate = useNavigate();
const location = useLocation();
const [permissions, setPermissions] = useState<JsonValue[]>([]);
useEffect(() => {
async function fetchPermissions() {
const { data } = await apiFetch.api.user.find.get();
if (Array.isArray(data?.permissions)) {
setPermissions(data.permissions);
} else {
setPermissions([]);
}
}
fetchPermissions();
}, []);
const isActive = (path: keyof typeof clientRoute) =>
location.pathname.startsWith(clientRoute[path]);
const navItems = [
{
key: "dashboard",
path: "/scr/dashboard/dashboard-home",
icon: <IconDashboard size={20} />,
label: "Dashboard Overview",
description: "Quick summary and insights",
},
{
key: "pengaduan",
path: "/scr/dashboard/pengaduan/list",
icon: <IconMessageReport size={20} />,
label: "Pengaduan Warga",
description: "Manage pengaduan warga",
},
{
key: "pelayanan",
path: "/scr/dashboard/pelayanan-surat/list-pelayanan",
icon: <IconFileCertificate size={20} />,
label: "Pelayanan Surat",
description: "Manage pelayanan surat",
},
{
key: "warga",
path: "/scr/dashboard/warga/list-warga",
icon: <IconUsersGroup size={20} />,
label: "Warga",
description: "Manage warga",
},
{
key: "setting",
path: "/scr/dashboard/setting/detail-setting",
icon: <IconSettings size={20} />,
label: "Setting",
@@ -249,12 +268,14 @@ function NavigationDashboard() {
"Manage setting (category pengaduan dan pelayanan surat, desa, etc)",
},
{
key: "api_key",
path: "/scr/dashboard/apikey/apikey",
icon: <IconKey size={20} />,
label: "API Key Manager",
description: "Create and manage API keys",
},
{
key: "credential",
path: "/scr/dashboard/credential/credential",
icon: <IconLock size={20} />,
label: "Credentials",
@@ -264,7 +285,7 @@ function NavigationDashboard() {
return (
<Stack gap="xs" p="sm">
{navItems.map((item) => (
{navItems.filter((item) => permissions.includes(item.key)).map((item) => (
<NavLink
key={item.path}
active={isActive(item.path as keyof typeof clientRoute)}

View File

@@ -31,6 +31,7 @@ import {
IconUser
} from "@tabler/icons-react";
import type { User } from "generated/prisma";
import type { JsonValue } from "generated/prisma/runtime/library";
import _ from "lodash";
import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
@@ -76,11 +77,17 @@ function DetailDataPengajuan({ data, syaratDokumen, dataText, onAction }: { data
const [host, setHost] = useState<User | null>(null);
const [noSurat, setNoSurat] = useState("");
const [openedPreview, setOpenedPreview] = useState(false);
const [permissions, setPermissions] = useState<JsonValue[]>([]);
useEffect(() => {
async function fetchHost() {
const { data } = await apiFetch.api.user.find.get();
setHost(data?.user ?? null);
if (data?.permissions && Array.isArray(data.permissions)) {
const onlySetting = data.permissions.filter((p: any) => p.startsWith("pelayanan"));
setPermissions(onlySetting);
}
}
fetchHost();
}, []);
@@ -276,6 +283,7 @@ function DetailDataPengajuan({ data, syaratDokumen, dataText, onAction }: { data
data?.status === "antrian" ? (
<Group justify="center" grow>
<Button
disabled={!permissions.includes("pelayanan.antrian.tolak")}
variant="light"
onClick={() => {
setCatModal("tolak");
@@ -285,6 +293,7 @@ function DetailDataPengajuan({ data, syaratDokumen, dataText, onAction }: { data
Tolak
</Button>
<Button
disabled={!permissions.includes("pelayanan.antrian.terima")}
variant="filled"
onClick={() => {
setCatModal("terima");
@@ -297,6 +306,7 @@ function DetailDataPengajuan({ data, syaratDokumen, dataText, onAction }: { data
) : data?.status === "diterima" ? (
<Group justify="center" grow>
<Button
disabled={!permissions.includes("pelayanan.diterima.tolak")}
variant="light"
onClick={() => {
setCatModal("tolak");
@@ -306,6 +316,7 @@ function DetailDataPengajuan({ data, syaratDokumen, dataText, onAction }: { data
Tolak
</Button>
<Button
disabled={!permissions.includes("pelayanan.diterima.setujui")}
variant="filled"
onClick={() => {
setCatModal("terima");

View File

@@ -175,7 +175,7 @@ function ListPelayananSurat({ status }: { status: StatusKey }) {
}
/>
</Group>
{list?.length === 0 ? (
{Array.isArray(list) && list?.length === 0 ? (
<Flex justify="center" align="center" py={"xl"}>
<Stack gap={4} align="center">
<IconFileSad size={32} color="gray" />

View File

@@ -31,6 +31,7 @@ import {
IconUser,
} from "@tabler/icons-react";
import type { User } from "generated/prisma";
import type { JsonValue } from "generated/prisma/runtime/library";
import _ from "lodash";
import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
@@ -77,11 +78,17 @@ function DetailDataPengaduan({ data, onAction }: { data: any, onAction: () => vo
useDisclosure(false);
const [keterangan, setKeterangan] = useState("");
const [host, setHost] = useState<User | null>(null);
const [permissions, setPermissions] = useState<JsonValue[]>([]);
useEffect(() => {
async function fetchHost() {
const { data } = await apiFetch.api.user.find.get();
setHost(data?.user ?? null);
if (data?.permissions && Array.isArray(data.permissions)) {
const onlySetting = data.permissions.filter((p: any) => p.startsWith("pengaduan"));
setPermissions(onlySetting);
}
}
fetchHost();
}, []);
@@ -294,6 +301,7 @@ function DetailDataPengaduan({ data, onAction }: { data: any, onAction: () => vo
<Group justify="center" grow>
<Button
variant="light"
disabled={!permissions.includes("pengaduan.antrian.tolak")}
onClick={() => {
setCatModal("tolak");
open();
@@ -303,6 +311,7 @@ function DetailDataPengaduan({ data, onAction }: { data: any, onAction: () => vo
</Button>
<Button
variant="filled"
disabled={!permissions.includes("pengaduan.antrian.terima")}
onClick={() => {
setCatModal("terima");
open();
@@ -315,6 +324,7 @@ function DetailDataPengaduan({ data, onAction }: { data: any, onAction: () => vo
<Group justify="center" grow>
<Button
variant="filled"
disabled={!permissions.includes("pengaduan.diterima.dikerjakan")}
onClick={() => {
setCatModal("terima");
open();
@@ -327,6 +337,7 @@ function DetailDataPengaduan({ data, onAction }: { data: any, onAction: () => vo
<Group justify="center" grow>
<Button
variant="filled"
disabled={!permissions.includes("pengaduan.dikerjakan.selesai")}
onClick={() => {
setCatModal("terima");
open();

View File

@@ -2,7 +2,9 @@ import DesaSetting from "@/components/DesaSetting";
import KategoriPelayananSurat from "@/components/KategoriPelayananSurat";
import KategoriPengaduan from "@/components/KategoriPengaduan";
import ProfileUser from "@/components/ProfileUser";
import UserRoleSetting from "@/components/UserRoleSetting";
import UserSetting from "@/components/UserSetting";
import apiFetch from "@/lib/apiFetch";
import {
Card,
Container,
@@ -14,14 +16,78 @@ import {
IconCategory2,
IconMailSpark,
IconUserCog,
IconUsersGroup,
IconUserScreen,
IconUsersGroup
} from "@tabler/icons-react";
import type { JsonValue } from "generated/prisma/runtime/library";
import { useEffect, useState } from "react";
import { useLocation } from "react-router-dom";
export default function DetailSettingPage() {
const { search } = useLocation();
const query = new URLSearchParams(search);
const type = query.get("type");
const [permissions, setPermissions] = useState<JsonValue[]>([]);
useEffect(() => {
async function fetchPermissions() {
const { data } = await apiFetch.api.user.find.get();
if (Array.isArray(data?.permissions)) {
const onlySetting = data.permissions.filter((p: any) => p.startsWith("setting"));
setPermissions(onlySetting);
} else {
setPermissions([]);
}
}
fetchPermissions();
}, []);
const navItems = [
{
key: "setting.profile",
path: "profile",
icon: <IconUserCog size={20} />,
label: "Profile",
description: "Manage profile settings",
},
{
key: "setting.user",
path: "user",
icon: <IconUsersGroup size={20} />,
label: "User",
description: "Manage user accounts",
},
{
key: "setting.user_role",
path: "role",
icon: <IconUserScreen size={20} />,
label: "Role",
description: "Manage user roles",
},
{
key: "setting.kategori_pengaduan",
path: "cat-pengaduan",
icon: <IconCategory2 size={20} />,
label: "Kategori Pengaduan",
description: "Manage complaint categories",
},
{
key: "setting.kategori_pelayanan",
path: "cat-pelayanan",
icon: <IconMailSpark size={20} />,
label: "Kategori Pelayanan Surat",
description: "Manage letter service categories",
},
{
key: "setting.desa",
path: "desa",
icon: <IconBuildingBank size={20} />,
label: "Desa",
description: "Manage desa information",
}
];
return (
<Container size="xl" py="xl" w={"100%"}>
@@ -38,36 +104,17 @@ export default function DetailSettingPage() {
boxShadow: "0 0 20px rgba(0,255,200,0.08)",
}}
>
<NavLink
href={`?type=profile`}
label="Profile"
leftSection={<IconUserCog size={16} stroke={1.5} />}
active={type === "profile" || !type}
/>
<NavLink
href={`?type=user`}
label="User"
leftSection={<IconUsersGroup size={16} stroke={1.5} />}
active={type === "user"}
/>
<NavLink
href={`?type=cat-pengaduan`}
label="Kategori Pengaduan"
leftSection={<IconCategory2 size={16} stroke={1.5} />}
active={type === "cat-pengaduan"}
/>
<NavLink
href={`?type=cat-pelayanan`}
label="Kategori Pelayanan Surat"
leftSection={<IconMailSpark size={16} stroke={1.5} />}
active={type === "cat-pelayanan"}
/>
<NavLink
href={`?type=desa`}
label="Desa"
leftSection={<IconBuildingBank size={16} stroke={1.5} />}
active={type === "desa"}
/>
{
navItems.filter((item) => permissions.includes(item.key)).map((item) => (
<NavLink
key={item.key}
href={'?type=' + item.path}
label={item.label}
leftSection={item.icon}
active={type === item.path || (!type && item.path === 'profile')}
/>
))
}
</Card>
</Grid.Col>
<Grid.Col span={9}>
@@ -83,15 +130,17 @@ export default function DetailSettingPage() {
}}
>
{type === "cat-pengaduan" ? (
<KategoriPengaduan />
<KategoriPengaduan permissions={permissions.filter((p) => typeof p === 'string' && p.startsWith("setting.kategori_pengaduan"))} />
) : type === "cat-pelayanan" ? (
<KategoriPelayananSurat />
<KategoriPelayananSurat permissions={permissions.filter((p) => typeof p === 'string' && p.startsWith("setting.kategori_pelayanan"))} />
) : type === "desa" ? (
<DesaSetting />
<DesaSetting permissions={permissions.filter((p) => typeof p === 'string' && p.startsWith("setting.desa"))} />
) : type === "user" ? (
<UserSetting />
<UserSetting permissions={permissions.filter((p) => typeof p === 'string' && p.startsWith("setting.user."))} />
) : type === "role" ? (
<UserRoleSetting permissions={permissions.filter((p) => typeof p === 'string' && p.startsWith("setting.user_role"))} />
) : (
<ProfileUser />
<ProfileUser permissions={permissions.filter((p) => typeof p === 'string' && p.startsWith("setting.profile"))} />
)}
</Card>
</Grid.Col>

View File

@@ -78,12 +78,12 @@ export default function ListWargaPage() {
</Table.Thead>
<Table.Tbody>
{
list?.length === 0 ? (
Array.isArray(list) && list?.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={3} align="center">Tidak ada data</Table.Td>
</Table.Tr>
) : (
list?.map((item, i) => (
Array.isArray(list) && list?.map((item, i) => (
<Table.Tr key={i}>
<Table.Td>{item.name}</Table.Td>
<Table.Td>{item.phone}</Table.Td>

View File

@@ -6,10 +6,16 @@ const UserRoute = new Elysia({
prefix: "user",
tags: ["user"],
})
.get('/find', (ctx) => {
.get('/find', async (ctx) => {
const { user } = ctx as any
const permissions = await prisma.role.findFirst({
where: { id: user?.roleId },
select: { permissions: true }
});
return {
user: user as User
user: user as User,
permissions: permissions?.permissions || []
}
}, {
detail: {
@@ -150,7 +156,11 @@ const UserRoute = new Elysia({
}
})
.get("/role", async () => {
const data = await prisma.role.findMany()
const data = await prisma.role.findMany({
where: {
isActive: true
}
})
return data
}, {
detail: {
@@ -182,5 +192,80 @@ const UserRoute = new Elysia({
description: "delete user",
}
})
.post("role-create", async ({ body }) => {
const { name, permission } = body;
const create = await prisma.role.create({
data: {
name,
permissions: permission
}
});
return {
success: true,
message: "Role created successfully",
};
}, {
body: t.Object({
name: t.String({ minLength: 1, error: "name is required" }),
permission: t.Array(t.Any(), { minItems: 1, error: "permission is required" })
}),
detail: {
summary: "create-role",
description: "create role",
}
})
.post("/role-update", async ({ body }) => {
const { id, name, permission } = body;
const update = await prisma.role.update({
where: {
id
},
data: {
name,
permissions: permission
}
});
return {
success: true,
message: "User role updated successfully",
};
}, {
body: t.Object({
id: t.String({ minLength: 1, error: "id is required" }),
name: t.String({ minLength: 1, error: "name is required" }),
permission: t.Array(t.String(), { minItems: 1, error: "permission is required" })
}),
detail: {
summary: "update-role",
description: "update role",
}
})
.post("role-delete", async ({ body }) => {
const { id } = body;
await prisma.role.update({
where: {
id
},
data: {
isActive: false
}
});
return {
success: true,
message: "Role deleted successfully",
};
}, {
body: t.Object({
id: t.String({ minLength: 1, error: "id is required" })
}),
detail: {
summary: "delete-role",
description: "delete role",
}
})
;
export default UserRoute