Merge pull request #24 from digitalsolutiongroup/feat/aspect-management-frontend

Slicing and Integration API for Aspect Management
This commit is contained in:
Abiyasa Putra Prasetya 2024-10-09 12:14:58 +07:00 committed by GitHub
commit 2a2f070711
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 595 additions and 12 deletions

View File

@ -28,6 +28,13 @@ const sidebarMenus: SidebarMenu[] = [
link: "/assessmentRequest", link: "/assessmentRequest",
color: "green", color: "green",
}, },
{
label: "Manajemen Aspek",
icon: { tb: "TbClipboardText" },
allowedPermissions: ["permissions.read"],
link: "/aspect",
color: "blue",
},
]; ];
export default sidebarMenus; export default sidebarMenus;

View File

@ -80,9 +80,19 @@ const managementAspectRoute = new Hono<HonoEnv>()
async (c) => { async (c) => {
const { includeTrashed, page, limit, q } = c.req.valid("query"); const { includeTrashed, page, limit, q } = c.req.valid("query");
const totalCountQuery = includeTrashed const aspectCountQuery = await db
? sql<number>`(SELECT count(DISTINCT ${aspects.id}) FROM ${aspects})` .select({
: sql<number>`(SELECT count(DISTINCT ${aspects.id}) FROM ${aspects} WHERE ${aspects.deletedAt} IS NULL)`; count: sql<number>`count(*)`,
})
.from(aspects)
.where(
and(
includeTrashed ? undefined : isNull(aspects.deletedAt),
q ? or(ilike(aspects.name, q), eq(aspects.id, q)) : undefined
)
);
const totalItems = Number(aspectCountQuery[0]?.count) || 0;
const aspectIdsQuery = await db const aspectIdsQuery = await db
.select({ .select({
@ -95,6 +105,7 @@ const managementAspectRoute = new Hono<HonoEnv>()
q ? or(ilike(aspects.name, q), eq(aspects.id, q)) : undefined q ? or(ilike(aspects.name, q), eq(aspects.id, q)) : undefined
) )
) )
.orderBy(aspects.name)
.offset(page * limit) .offset(page * limit)
.limit(limit); .limit(limit);
@ -128,11 +139,11 @@ const managementAspectRoute = new Hono<HonoEnv>()
FROM ${questions} FROM ${questions}
WHERE ${questions.subAspectId} = ${subAspects.id} WHERE ${questions.subAspectId} = ${subAspects.id}
)`.as('questionCount'), )`.as('questionCount'),
fullCount: totalCountQuery,
}) })
.from(aspects) .from(aspects)
.leftJoin(subAspects, eq(subAspects.aspectId, aspects.id)) .leftJoin(subAspects, eq(subAspects.aspectId, aspects.id))
.where(inArray(aspects.id, aspectIds)); .where(inArray(aspects.id, aspectIds))
.orderBy(aspects.name);
// Grouping sub aspects by aspect ID // Grouping sub aspects by aspect ID
const groupedResult = result.reduce((acc, curr) => { const groupedResult = result.reduce((acc, curr) => {
@ -176,8 +187,8 @@ const managementAspectRoute = new Hono<HonoEnv>()
data: groupedArray, data: groupedArray,
_metadata: { _metadata: {
currentPage: page, currentPage: page,
totalPages: Math.ceil((Number(result[0]?.fullCount) ?? 0) / limit), totalPages: Math.ceil(totalItems / limit),
totalItems: Number(result[0]?.fullCount) ?? 0, totalItems,
perPage: limit, perPage: limit,
}, },
}); });
@ -287,10 +298,23 @@ const managementAspectRoute = new Hono<HonoEnv>()
if (aspectData.subAspects) { if (aspectData.subAspects) {
const subAspectsArray = JSON.parse(aspectData.subAspects) as string[]; const subAspectsArray = JSON.parse(aspectData.subAspects) as string[];
// Insert new sub aspects into the database without checking for sub aspect duplication // Create a Set to check for duplicates
if (subAspectsArray.length) { const uniqueSubAspects = new Set<string>();
// Filter out duplicates
const filteredSubAspects = subAspectsArray.filter((subAspect) => {
if (uniqueSubAspects.has(subAspect)) {
return false; // Skip duplicates
}
uniqueSubAspects.add(subAspect);
return true; // Keep unique sub-aspects
});
// Check if there are any unique sub aspects to insert
if (filteredSubAspects.length) {
// Insert new sub aspects into the database
await db.insert(subAspects).values( await db.insert(subAspects).values(
subAspectsArray.map((subAspect) => ({ filteredSubAspects.map((subAspect) => ({
aspectId, aspectId,
name: subAspect, name: subAspect,
})) }))
@ -379,10 +403,20 @@ const managementAspectRoute = new Hono<HonoEnv>()
); );
} }
// Create a Set to check for duplicate sub-aspects
const uniqueSubAspectNames = new Set(currentSubAspects.map(sub => sub.name));
// Update or add new sub aspects // Update or add new sub aspects
for (const subAspect of newSubAspects) { for (const subAspect of newSubAspects) {
const existingSubAspect = currentSubAspectMap.has(subAspect.id); const existingSubAspect = currentSubAspectMap.has(subAspect.id);
// Check for duplicate sub-aspect names
if (uniqueSubAspectNames.has(subAspect.name) && !existingSubAspect) {
throw notFound({
message: `Sub aspect name "${subAspect.name}" already exists for this aspect.`,
});
}
if (existingSubAspect) { if (existingSubAspect) {
// Update if sub aspect already exists // Update if sub aspect already exists
await db await db
@ -402,12 +436,14 @@ const managementAspectRoute = new Hono<HonoEnv>()
await db await db
.insert(subAspects) .insert(subAspects)
.values({ .values({
id: subAspect.id,
aspectId, aspectId,
name: subAspect.name, name: subAspect.name,
createdAt: new Date(), createdAt: new Date(),
}); });
} }
// Add the name to the Set after processing
uniqueSubAspectNames.add(subAspect.name);
} }
return c.json({ return c.json({

View File

@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@emotion/react": "^11.11.4", "@emotion/react": "^11.11.4",
"@hookform/resolvers": "^3.9.0", "@hookform/resolvers": "^3.9.0",
"@paralleldrive/cuid2": "^2.2.2",
"@mantine/core": "^7.10.2", "@mantine/core": "^7.10.2",
"@mantine/dates": "^7.10.2", "@mantine/dates": "^7.10.2",
"@mantine/form": "^7.10.2", "@mantine/form": "^7.10.2",

View File

@ -0,0 +1,97 @@
import client from "@/honoClient";
import { Button, Flex, Modal, Text } from "@mantine/core";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getRouteApi, useSearch } from "@tanstack/react-router";
import { deleteAspect } from "../queries/aspectQueries";
import { notifications } from "@mantine/notifications";
import fetchRPC from "@/utils/fetchRPC";
const routeApi = getRouteApi("/_dashboardLayout/aspect/");
export default function AspectDeleteModal() {
const queryClient = useQueryClient();
const searchParams = useSearch({ from: "/_dashboardLayout/aspect/" }) as {
delete: string;
};
const aspectId = searchParams.delete;
const navigate = routeApi.useNavigate();
const aspectQuery = useQuery({
queryKey: ["management-aspect", aspectId],
queryFn: async () => {
if (!aspectId) return null;
return await fetchRPC(
client["management-aspect"][":id"].$get({
param: {
id: aspectId,
},
query: {},
})
);
},
});
const mutation = useMutation({
mutationKey: ["deleteAspectMutation"],
mutationFn: async ({ id }: { id: string }) => {
return await deleteAspect(id);
},
onError: (error: unknown) => {
if (error instanceof Error) {
notifications.show({
message: error.message,
color: "red",
});
}
},
onSuccess: () => {
notifications.show({
message: "Aspek berhasil dihapus.",
color: "green",
});
queryClient.removeQueries({ queryKey: ["management-aspect", aspectId] });
queryClient.invalidateQueries({ queryKey: ["management-aspect"] });
navigate({ search: {} });
},
});
const isModalOpen = Boolean(searchParams.delete && aspectQuery.data);
return (
<Modal
opened={isModalOpen}
onClose={() => navigate({ search: {} })}
title={`Konfirmasi Hapus`}
>
<Text size="sm">
Apakah Anda yakin ingin menghapus aspek{" "}
<Text span fw={700}>
{aspectQuery.data?.name}
</Text>
? Tindakan ini tidak dapat diubah.
</Text>
{/* Buttons */}
<Flex justify="flex-end" align="center" gap="lg" mt="lg">
<Button
variant="outline"
onClick={() => navigate({ search: {} })}
disabled={mutation.isPending}
>
Batal
</Button>
<Button
variant="subtle"
type="submit"
color="red"
loading={mutation.isPending}
onClick={() => mutation.mutate({ id: aspectId })}
>
Hapus Aspek
</Button>
</Flex>
</Modal>
);
}

View File

@ -0,0 +1,248 @@
import { Button, Flex, Modal, ScrollArea, TextInput, Group, Text } from "@mantine/core";
import { useForm } from "@mantine/form";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { getRouteApi } from "@tanstack/react-router";
import { createAspect, updateAspect, getAspectByIdQueryOptions } from "../queries/aspectQueries";
import { TbDeviceFloppy } from "react-icons/tb";
import { useEffect } from "react";
import { notifications } from "@mantine/notifications";
import FormResponseError from "@/errors/FormResponseError";
import { createId } from "@paralleldrive/cuid2";
// Initialize route API
const routeApi = getRouteApi("/_dashboardLayout/aspect/");
export default function AspectFormModal() {
const queryClient = useQueryClient();
const navigate = routeApi.useNavigate();
const searchParams = routeApi.useSearch();
const dataId = searchParams.detail || searchParams.edit;
const isModalOpen = Boolean(dataId || searchParams.create);
const formType = searchParams.detail ? "detail" : searchParams.edit ? "edit" : "create";
// Fetch aspect data if editing or viewing details
const aspectQuery = useQuery(getAspectByIdQueryOptions(dataId));
const modalTitle = `${formType.charAt(0).toUpperCase() + formType.slice(1)} Aspek`;
const form = useForm({
initialValues: {
id: "",
name: "",
subAspects: [{ id: "", name: "", questionCount: 0 }] as { id: string; name: string; questionCount: number }[],
},
});
useEffect(() => {
const data = aspectQuery.data;
if (!data) {
form.reset();
return;
}
form.setValues({
id: data.id,
name: data.name,
subAspects: data.subAspects?.map(subAspect => ({
id: subAspect.id || "",
name: subAspect.name,
questionCount: subAspect.questionCount || 0,
})) || [],
});
form.setErrors({});
}, [aspectQuery.data]);
const mutation = useMutation({
mutationKey: ["aspectMutation"],
mutationFn: async (
options:
| { action: "edit"; data: Parameters<typeof updateAspect>[0] }
| { action: "create"; data: Parameters<typeof createAspect>[0] }
) => {
return options.action === "edit"
? await updateAspect(options.data)
: await createAspect(options.data);
},
onError: (error: unknown) => {
if (error instanceof FormResponseError) {
form.setErrors(error.formErrors);
return;
}
if (error instanceof Error) {
notifications.show({
message: error.message,
color: "red",
});
}
},
});
type CreateAspectPayload = {
name: string;
subAspects?: string;
};
type EditAspectPayload = {
id: string;
name: string;
subAspects?: string;
};
const handleSubmit = async (values: typeof form.values) => {
try {
// Name field validation
if (values.name.trim() === "") {
form.setErrors({ name: "Nama aspek harus diisi" });
return;
}
let payload: CreateAspectPayload | EditAspectPayload;
if (formType === "create") {
payload = {
name: values.name,
subAspects: values.subAspects.length > 0
? JSON.stringify(
values.subAspects
.filter(subAspect => subAspect.name.trim() !== "")
.map(subAspect => subAspect.name)
)
: "",
};
await createAspect(payload);
} else if (formType === "edit") {
// Add validation for aspect name here
payload = {
id: values.id,
name: values.name,
subAspects: values.subAspects.length > 0
? JSON.stringify(
values.subAspects
.filter(subAspect => subAspect.name.trim() !== "")
.map(subAspect => ({
id: subAspect.id || "",
name: subAspect.name,
questionCount: subAspect.questionCount || 0,
}))
)
: "",
};
await updateAspect(payload);
}
queryClient.invalidateQueries({ queryKey: ["management-aspect"] });
notifications.show({
message: `Aspek ${formType === "create" ? "berhasil dibuat" : "berhasil diedit"}`,
});
navigate({ search: {} });
} catch (error) {
console.error("Error during submit:", error);
if (error instanceof Error && error.message === "Aspect name already exists") {
notifications.show({
message: "Nama aspek sudah ada. Silakan gunakan nama lain.",
color: "red",
});
} else {
notifications.show({
message: "Nama Sub Aspek sudah ada. Silakan gunakan nama lain.",
color: "red",
});
}
}
};
return (
<Modal
opened={isModalOpen}
onClose={() => navigate({ search: {} })}
title={modalTitle}
scrollAreaComponent={ScrollArea.Autosize}
size="md"
>
<form onSubmit={form.onSubmit((values) => handleSubmit(values))}>
<TextInput
type="text"
label="Nama"
{...form.getInputProps("name")}
disabled={formType === "detail"}
error={form.errors.name}
/>
{form.values.subAspects.map((field, index) => (
<Group key={index} mt="md" align="center">
<TextInput
type="text"
label={`Sub Aspek ${index + 1}`}
value={field.name}
onChange={(event) => {
const newSubAspects = [...form.values.subAspects];
newSubAspects[index] = { ...newSubAspects[index], name: event.target.value };
form.setValues({ subAspects: newSubAspects });
}}
disabled={formType === "detail"}
style={{ flex: 1 }}
/>
{formType === "detail" && (
<Text>Jumlah Soal: {field.questionCount}</Text>
)}
{formType !== "detail" && (
<Button
className="mt-6"
variant="outline"
onClick={() => {
const newSubAspects = form.values.subAspects.filter((_, i) => i !== index);
form.setValues({ subAspects: newSubAspects });
}}
>
Hapus
</Button>
)}
</Group>
))}
{formType !== "detail" && (
<Button
variant="outline"
mt="md"
onClick={() => {
const newSubAspects = [
...form.values.subAspects,
{ id: createId(), name: "", questionCount: 0 }
];
form.setValues({ subAspects: newSubAspects });
}}
>
Tambah Sub Aspek
</Button>
)}
{/* Buttons */}
<Flex justify="flex-end" align="center" gap="lg" mt="lg">
<Button
variant="outline"
onClick={() => navigate({ search: {} })}
disabled={mutation.isPending}
>
Tutup
</Button>
{formType !== "detail" && (
<Button
variant="filled"
leftSection={<TbDeviceFloppy size={20} />}
type="submit"
loading={mutation.isPending}
>
Simpan
</Button>
)}
</Flex>
</form>
</Modal>
);
}

View File

@ -0,0 +1,83 @@
import client from "@/honoClient";
import fetchRPC from "@/utils/fetchRPC";
import { queryOptions } from "@tanstack/react-query";
import { InferRequestType } from "hono";
export const aspectQueryOptions = (page: number, limit: number, q?: string) =>
queryOptions({
queryKey: ["management-aspect", { page, limit, q }],
queryFn: async () => {
const response = await fetchRPC(
client["management-aspect"].$get({
query: {
limit: String(limit),
page: String(page),
q,
},
})
);
return response;
},
});
export const getAspectByIdQueryOptions = (aspectId: string | undefined) =>
queryOptions({
queryKey: ["management-aspect", aspectId],
queryFn: () =>
fetchRPC(
client["management-aspect"][":id"].$get({
param: {
id: aspectId!,
},
query: {},
})
),
enabled: Boolean(aspectId),
});
export const createAspect = async (
json: { name: string; subAspects?: string }
) => {
try {
return await fetchRPC(
client["management-aspect"].$post({
json,
})
);
} catch (error) {
console.error("Error creating aspect:", error);
throw error;
}
};
export const updateAspect = async (
form: { id: string; name: string; subAspects?: string }
) => {
try {
const payload = {
name: form.name,
subAspects: form.subAspects
? JSON.parse(form.subAspects)
: [],
};
return await fetchRPC(
client["management-aspect"][":id"].$patch({
param: {
id: form.id,
},
json: payload,
})
);
} catch (error) {
console.error("Error updating aspect:", error);
throw error;
}
};
export const deleteAspect = async (id: string) => {
return await fetchRPC(
(client["management-aspect"] as { [key: string]: any })[id].$delete()
);
};

View File

@ -0,0 +1,93 @@
import { aspectQueryOptions } from "@/modules/aspectManagement/queries/aspectQueries";
import PageTemplate from "@/components/PageTemplate";
import { createLazyFileRoute } from "@tanstack/react-router";
import AspectFormModal from "@/modules/aspectManagement/modals/AspectFormModal";
import ExtractQueryDataType from "@/types/ExtractQueryDataType";
import { createColumnHelper } from "@tanstack/react-table";
import { Flex } from "@mantine/core";
import createActionButtons from "@/utils/createActionButton";
import { TbEye, TbPencil, TbTrash } from "react-icons/tb";
import AspectDeleteModal from "@/modules/aspectManagement/modals/AspectDeleteModal";
export const Route = createLazyFileRoute("/_dashboardLayout/aspect/")({
component: AspectPage,
});
type DataType = ExtractQueryDataType<typeof aspectQueryOptions>;
const columnHelper = createColumnHelper<DataType>();
export default function AspectPage() {
return (
<PageTemplate
title="Manajemen Aspek"
queryOptions={aspectQueryOptions}
modals={[<AspectFormModal />, <AspectDeleteModal />]}
columnDefs={[
// Number of columns
columnHelper.display({
header: "#",
cell: (props) => props.row.index + 1,
}),
// Aspect columns
columnHelper.display({
header: "Nama Aspek",
cell: (props) => props.row.original.name || "Tidak ada Aspek",
}),
// Sub aspect columns
columnHelper.display({
header: "Sub Aspek",
cell: (props) => {
const subAspects = props.row.original.subAspects || [];
return subAspects.length > 0 ? (
<span>
{subAspects.map((subAspect, index) => (
<span key={subAspect.id}>
{subAspect.name}
{index < subAspects.length - 1 ? ", " : ""}
</span>
))}
</span>
) : (
<span>Tidak ada Sub Aspek</span>
);
},
}),
// Actions columns
columnHelper.display({
header: "Aksi",
cell: (props) => (
<Flex gap="xs">
{createActionButtons([
{
label: "Detail",
permission: true,
action: `?detail=${props.row.original.id}`,
color: "green",
icon: <TbEye />,
},
{
label: "Edit",
permission: true,
action: `?edit=${props.row.original.id}`,
color: "orange",
icon: <TbPencil />,
},
{
label: "Hapus",
permission: true,
action: `?delete=${props.row.original.id}`,
color: "red",
icon: <TbTrash />,
},
])}
</Flex>
),
}),
]}
/>
);
}

View File

@ -0,0 +1,18 @@
import { aspectQueryOptions } from "@/modules/aspectManagement/queries/aspectQueries";
import { createFileRoute } from "@tanstack/react-router";
import { z } from "zod";
const searchParamSchema = z.object({
create: z.boolean().default(false).optional(),
edit: z.string().default("").optional(),
delete: z.string().default("").optional(),
detail: z.string().default("").optional(),
});
export const Route = createFileRoute("/_dashboardLayout/aspect/")({
validateSearch: searchParamSchema,
loader: ({ context: { queryClient } }) => {
queryClient.ensureQueryData(aspectQueryOptions(0, 10));
},
});