upd: dashboard admin

Deskripsi:
- databse
- seeder
- list user role

NO Issues
This commit is contained in:
2025-11-24 14:27:19 +08:00
parent c72ef5a755
commit 0a3afb7b9c
7 changed files with 798 additions and 16 deletions

View File

@@ -9,11 +9,12 @@ 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?
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

@@ -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,465 @@
import apiFetch from "@/lib/apiFetch";
import {
ActionIcon,
Button,
Divider,
Flex,
Group,
Input,
Modal,
Select,
Stack,
Table,
Text,
Title,
Tooltip,
} from "@mantine/core";
import { useDisclosure, useShallowEffect } from "@mantine/hooks";
import { IconEdit, IconPlus, IconTrash } from "@tabler/icons-react";
import { useState } from "react";
import useSWR from "swr";
import notification from "./notificationGlobal";
import PermissionRole from "./PermissionRole";
export default function UserRoleSetting() {
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: "",
phone: "",
email: "",
roleId: "",
});
const [dataTambah, setDataTambah] = useState({
name: "",
email: "",
roleId: "",
password: "",
phone: "",
});
const [error, setError] = useState({
name: false,
email: false,
roleId: false,
password: false,
phone: false,
});
useShallowEffect(() => {
mutate();
}, []);
async function handleCreate() {
try {
setBtnLoading(true);
const res = await apiFetch.api.user.create.post(dataTambah);
if (res.status === 200) {
mutate();
closeTambah();
setDataTambah({
name: "",
email: "",
roleId: "",
password: "",
phone: "",
});
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.delete.post({ id: dataDelete });
if (res.status === 200) {
mutate();
closeDelete();
notification({
title: "Success",
message: "Your user have been deleted",
type: "success",
});
} else {
notification({
title: "Error",
message: "Failed to delete user",
type: "error",
});
}
} catch (error) {
console.error(error);
notification({
title: "Error",
message: "Failed to delete user",
type: "error",
});
} finally {
setBtnLoading(false);
}
}
function chooseEdit({
data,
}: {
data: {
id: string;
name: string;
phone: string;
email: string;
roleId: string;
};
}) {
setDataEdit(data);
open();
}
function onValidation({
kat,
value,
aksi,
}: {
kat: "name" | "email" | "roleId" | "password" | "phone";
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 });
}
}
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>
<Select
label="Role"
placeholder="Pilih Role"
data={listRole.map((r: any) => ({
value: r.id,
label: r.name,
}))}
value={dataTambah.roleId || null}
error={error.roleId ? "Field is required" : ""}
onChange={(_value, option) => {
onValidation({
kat: "roleId",
value: option?.value,
aksi: "tambah",
});
}}
/>
<Input.Wrapper label="Phone" description="">
<Input
value={dataTambah.phone}
onChange={(e) =>
onValidation({
kat: "phone",
value: e.target.value,
aksi: "tambah",
})
}
/>
</Input.Wrapper>
<Input.Wrapper
label="Email"
description=""
error={error.email ? "Field is required" : ""}
>
<Input
value={dataTambah.email}
onChange={(e) =>
onValidation({
kat: "email",
value: e.target.value,
aksi: "tambah",
})
}
/>
</Input.Wrapper>
<Input.Wrapper
label="Password"
description=""
error={error.password ? "Field is required" : ""}
>
<Input
value={dataTambah.password}
onChange={(e) =>
onValidation({
kat: "password",
value: e.target.value,
aksi: "tambah",
})
}
/>
</Input.Wrapper>
<Group justify="center" grow>
<Button variant="light" onClick={closeTambah}>
Batal
</Button>
<Button
variant="filled"
onClick={handleCreate}
disabled={
btnDisable ||
dataTambah.name.length < 1 ||
dataTambah.email.length < 1 ||
dataTambah.password.length < 1 ||
dataTambah.roleId.length < 1 ||
dataTambah.phone.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 user 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>
<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="Edit User">
<ActionIcon
variant="light"
size="sm"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => chooseEdit({ data: v })}
>
<IconEdit size={20} />
</ActionIcon>
</Tooltip>
<Tooltip label="Delete User">
<ActionIcon
variant="light"
size="sm"
color="red"
style={{ boxShadow: "0 0 8px rgba(0,255,200,0.2)" }}
onClick={() => {
setDataDelete(v.id);
openDelete();
}}
>
<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

@@ -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;
}

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

@@ -0,0 +1,178 @@
{
"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": "credential",
"label": "Credential",
"default": true,
"children": []
}
]
}

View File

@@ -2,6 +2,7 @@ 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 {
Card,
@@ -14,7 +15,8 @@ import {
IconCategory2,
IconMailSpark,
IconUserCog,
IconUsersGroup,
IconUserScreen,
IconUsersGroup
} from "@tabler/icons-react";
import { useLocation } from "react-router-dom";
@@ -50,6 +52,12 @@ export default function DetailSettingPage() {
leftSection={<IconUsersGroup size={16} stroke={1.5} />}
active={type === "user"}
/>
<NavLink
href={`?type=role`}
label="Role"
leftSection={<IconUserScreen size={16} stroke={1.5} />}
active={type === "role"}
/>
<NavLink
href={`?type=cat-pengaduan`}
label="Kategori Pengaduan"
@@ -90,6 +98,8 @@ export default function DetailSettingPage() {
<DesaSetting />
) : type === "user" ? (
<UserSetting />
) : type === "role" ? (
<UserRoleSetting />
) : (
<ProfileUser />
)}