This commit is contained in:
Muhammad Eko 2024-10-03 14:43:27 +07:00
parent cc51d06c97
commit 8ef2970c25
16 changed files with 385 additions and 146 deletions

View File

@ -22,7 +22,8 @@
"react": "^18", "react": "^18",
"react-dom": "^18", "react-dom": "^18",
"react-redux": "^9.1.2", "react-redux": "^9.1.2",
"react-tooltip": "^5.28.0" "react-tooltip": "^5.28.0",
"redux-persist": "^6.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^20", "@types/node": "^20",

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -2,7 +2,7 @@
import { useAppSelector } from "@/lib/hooks"; import { useAppSelector } from "@/lib/hooks";
import { useGetAttendanceRangeQuery, useGetMonthlyAttendanceQuery, useGetOrganizationAttendanceQuery } from "@/services/api"; import { useGetAttendanceRangeQuery, useGetMonthlyAttendanceQuery, useGetOrganizationAttendanceQuery } from "@/services/api";
import { ExclamationCircleIcon } from "@heroicons/react/24/outline"; import { ExclamationCircleIcon } from "@heroicons/react/24/outline";
import { BarChart, ChartsTooltip, LineChart, pieArcClasses, pieArcLabelClasses, PieChart } from "@mui/x-charts"; import { BarChart, ChartsAxisTooltipContent, ChartsTooltip, LineChart, pieArcClasses, pieArcLabelClasses, PieChart } from "@mui/x-charts";
import { ChartsNoDataOverlay } from "@mui/x-charts/ChartsOverlay"; import { ChartsNoDataOverlay } from "@mui/x-charts/ChartsOverlay";
import { formatDate } from "date-fns"; import { formatDate } from "date-fns";
@ -74,19 +74,22 @@ export default function AbsensiPage() {
<div className="col-span-8 bg-white py-4 pl-4 rounded-lg max-h-[420px] flex flex-col"> <div className="col-span-8 bg-white py-4 pl-4 rounded-lg max-h-[420px] flex flex-col">
<div className="text-xl font-bold">Data Kehadiran Karyawan Setiap Perusahaan</div> <div className="text-xl font-bold">Data Kehadiran Karyawan Setiap Perusahaan</div>
<div className="flex-1 min-h-[300px]"> <div className="flex-1 min-h-[300px]">
{attendanceSummary && <BarChart className="w-full" dataset={attendanceSummary} series={[ <BarChart className="w-full" dataset={attendanceSummary ?? []} series={[
{dataKey: "count", label: (v) => v === "tooltip" ? "Attendance" : undefined!, color: "#2385DE", stack: "attendance", stackOffset: "expand", valueFormatter: (v) => v + " mandays"}, {dataKey: "count", label: (v) => undefined!, color: "#2385DE", stack: "attendance", stackOffset: "expand", valueFormatter: (v) => undefined!},
{dataKey: "absent", label: (v) => v === "tooltip" ? "Absent" : undefined!, color: "#F7B500", stack: "attendance", stackOffset: "expand", valueFormatter: (v) => v + " mandays"}, {dataKey: "absent", label: (v) => undefined!, color: "#F7B500", stack: "attendance", stackOffset: "expand", valueFormatter: (v) => undefined!},
]} xAxis={[ ]} xAxis={[
{dataKey: "organization_code", label: "Nama Perusahaan", scaleType: "band", valueFormatter: (v, context) => context.location === "tooltip" ? attendanceSummary.find(e => e.organization_code === v)?.organization_name : v} {dataKey: "organization_code", label: "Nama Perusahaan", scaleType: "band", valueFormatter: (v, context) => context.location === "tooltip" ? attendanceSummary?.find(e => e.organization_code === v)?.organization_name + ` (${Math.round(attendanceSummary?.find(e => e.organization_code === v)?.count!/attendanceSummary?.find(e => e.organization_code === v)?.workdays! * 100)}%)` : v }
]} ]}
yAxis={[ yAxis={[
{dataKey: "count",scaleType: "linear", valueFormatter: (v) => (v*100)+"%"} {dataKey: "count",scaleType: "linear", valueFormatter: (v) => (v*100)+"%"}
]} ]}
tooltip={{
trigger: "axis",
}}
slots={{ slots={{
noDataOverlay: ()=> <ChartsNoDataOverlay message="Data belum tersedia" />, noDataOverlay: ()=> <ChartsNoDataOverlay message="Data belum tersedia" />,
}} }}
/>} />
</div> </div>
</div> </div>
<div className="col-span-4 bg-white rounded-lg py-4 pl-4 max-h-[420px] flex flex-col"> <div className="col-span-4 bg-white rounded-lg py-4 pl-4 max-h-[420px] flex flex-col">

View File

@ -14,6 +14,7 @@ import { useAppDispatch, useAppSelector } from "@/lib/hooks";
import { setFilter } from "@/lib/slice/filter"; import { setFilter } from "@/lib/slice/filter";
import { format } from "date-fns"; import { format } from "date-fns";
import { useGetFilterOptionsQuery } from "@/services/api"; import { useGetFilterOptionsQuery } from "@/services/api";
import { NeedLoginRoute } from "../RouteGuard";
export default function DashboardLayout({children}:{children: React.ReactNode}) { export default function DashboardLayout({children}:{children: React.ReactNode}) {
@ -25,128 +26,130 @@ export default function DashboardLayout({children}:{children: React.ReactNode})
const [region, setRegion] = React.useState(""); const [region, setRegion] = React.useState("");
return ( return (
<div className="flex min-h-screen"> <NeedLoginRoute role="Dashboard">
<div className="w-80 flex flex-col sticky h-screen"> <div className="flex min-h-screen">
<div className="w-80"> <div className="w-80 flex flex-col sticky h-screen">
<Image src={logo} alt="logo" sizes="100" className="w-96"/> <div className="w-80">
</div> <Image src={logo} alt="logo" sizes="100" className="w-96"/>
<div className="h-4 bg-white"/> </div>
<div className="flex flex-col flex-1 from-[#A36A4D] bg-gradient-to-b to-[#5B2D14] text-white py-8"> <div className="h-4 bg-white"/>
<div className="text-[20px] text-white ml-8 mb-4">Halaman</div> <div className="flex flex-col flex-1 from-[#A36A4D] bg-gradient-to-b to-[#5B2D14] text-white py-8">
<Link className={`flex gap-4 px-8 py-4 ${pathname === '/karyawan' ? `bg-white text-[#664228]`:`text-white hover:bg-white hover:text-[#664228]`} items-center`} href="/karyawan"> <div className="text-[20px] text-white ml-8 mb-4">Halaman</div>
<svg width="16" height="18" viewBox="0 0 16 18" fill="none" xmlns="http://www.w3.org/2000/svg" className="fill-current"> <Link className={`flex gap-4 px-8 py-4 ${pathname === '/karyawan' ? `bg-white text-[#664228]`:`text-white hover:bg-white hover:text-[#664228]`} items-center`} href="/karyawan">
<path d="M2 16H5V10H11V16H14V7L8 2.5L2 7V16ZM0 18V6L8 0L16 6V18H9V12H7V18H0Z" /> <svg width="16" height="18" viewBox="0 0 16 18" fill="none" xmlns="http://www.w3.org/2000/svg" className="fill-current">
</svg> <path d="M2 16H5V10H11V16H14V7L8 2.5L2 7V16ZM0 18V6L8 0L16 6V18H9V12H7V18H0Z" />
<span className="font-semibold text-base">Data Karyawan</span> </svg>
</Link> <span className="font-semibold text-base">Data Karyawan</span>
<Link className={`flex gap-4 px-8 py-4 ${pathname === '/absensi' ? `bg-white text-[#664228]`:`text-white hover:bg-white hover:text-[#664228]`} items-center`} href="/absensi"> </Link>
<svg width="23" height="16" viewBox="0 0 23 16" fill="none" xmlns="http://www.w3.org/2000/svg" className="fill-current"> <Link className={`flex gap-4 px-8 py-4 ${pathname === '/absensi' ? `bg-white text-[#664228]`:`text-white hover:bg-white hover:text-[#664228]`} items-center`} href="/absensi">
<path d="M16.55 8L13 4.45L14.425 3.05L16.55 5.175L20.8 0.925L22.2 2.35L16.55 8ZM8 8C6.9 8 5.95833 7.60833 5.175 6.825C4.39167 6.04167 4 5.1 4 4C4 2.9 4.39167 1.95833 5.175 1.175C5.95833 0.391667 6.9 0 8 0C9.1 0 10.0417 0.391667 10.825 1.175C11.6083 1.95833 12 2.9 12 4C12 5.1 11.6083 6.04167 10.825 6.825C10.0417 7.60833 9.1 8 8 8ZM0 16V13.2C0 12.6333 0.145833 12.1125 0.4375 11.6375C0.729167 11.1625 1.11667 10.8 1.6 10.55C2.63333 10.0333 3.68333 9.64583 4.75 9.3875C5.81667 9.12917 6.9 9 8 9C9.1 9 10.1833 9.12917 11.25 9.3875C12.3167 9.64583 13.3667 10.0333 14.4 10.55C14.8833 10.8 15.2708 11.1625 15.5625 11.6375C15.8542 12.1125 16 12.6333 16 13.2V16H0ZM2 14H14V13.2C14 13.0167 13.9542 12.85 13.8625 12.7C13.7708 12.55 13.65 12.4333 13.5 12.35C12.6 11.9 11.6917 11.5625 10.775 11.3375C9.85833 11.1125 8.93333 11 8 11C7.06667 11 6.14167 11.1125 5.225 11.3375C4.30833 11.5625 3.4 11.9 2.5 12.35C2.35 12.4333 2.22917 12.55 2.1375 12.7C2.04583 12.85 2 13.0167 2 13.2V14ZM8 6C8.55 6 9.02083 5.80417 9.4125 5.4125C9.80417 5.02083 10 4.55 10 4C10 3.45 9.80417 2.97917 9.4125 2.5875C9.02083 2.19583 8.55 2 8 2C7.45 2 6.97917 2.19583 6.5875 2.5875C6.19583 2.97917 6 3.45 6 4C6 4.55 6.19583 5.02083 6.5875 5.4125C6.97917 5.80417 7.45 6 8 6Z"/> <svg width="23" height="16" viewBox="0 0 23 16" fill="none" xmlns="http://www.w3.org/2000/svg" className="fill-current">
</svg> <path d="M16.55 8L13 4.45L14.425 3.05L16.55 5.175L20.8 0.925L22.2 2.35L16.55 8ZM8 8C6.9 8 5.95833 7.60833 5.175 6.825C4.39167 6.04167 4 5.1 4 4C4 2.9 4.39167 1.95833 5.175 1.175C5.95833 0.391667 6.9 0 8 0C9.1 0 10.0417 0.391667 10.825 1.175C11.6083 1.95833 12 2.9 12 4C12 5.1 11.6083 6.04167 10.825 6.825C10.0417 7.60833 9.1 8 8 8ZM0 16V13.2C0 12.6333 0.145833 12.1125 0.4375 11.6375C0.729167 11.1625 1.11667 10.8 1.6 10.55C2.63333 10.0333 3.68333 9.64583 4.75 9.3875C5.81667 9.12917 6.9 9 8 9C9.1 9 10.1833 9.12917 11.25 9.3875C12.3167 9.64583 13.3667 10.0333 14.4 10.55C14.8833 10.8 15.2708 11.1625 15.5625 11.6375C15.8542 12.1125 16 12.6333 16 13.2V16H0ZM2 14H14V13.2C14 13.0167 13.9542 12.85 13.8625 12.7C13.7708 12.55 13.65 12.4333 13.5 12.35C12.6 11.9 11.6917 11.5625 10.775 11.3375C9.85833 11.1125 8.93333 11 8 11C7.06667 11 6.14167 11.1125 5.225 11.3375C4.30833 11.5625 3.4 11.9 2.5 12.35C2.35 12.4333 2.22917 12.55 2.1375 12.7C2.04583 12.85 2 13.0167 2 13.2V14ZM8 6C8.55 6 9.02083 5.80417 9.4125 5.4125C9.80417 5.02083 10 4.55 10 4C10 3.45 9.80417 2.97917 9.4125 2.5875C9.02083 2.19583 8.55 2 8 2C7.45 2 6.97917 2.19583 6.5875 2.5875C6.19583 2.97917 6 3.45 6 4C6 4.55 6.19583 5.02083 6.5875 5.4125C6.97917 5.80417 7.45 6 8 6Z"/>
<span className="font-semibold text-base">Absensi</span> </svg>
</Link> <span className="font-semibold text-base">Absensi</span>
<Link className={`flex gap-4 px-8 py-4 ${pathname === '/turnover' ? `bg-white text-[#664228]`:`text-white hover:bg-white hover:text-[#664228]`} items-center`} href="/turnover"> </Link>
<svg width="22" height="18" viewBox="0 0 22 18" fill="none" xmlns="http://www.w3.org/2000/svg" className="fill-current"> <Link className={`flex gap-4 px-8 py-4 ${pathname === '/turnover' ? `bg-white text-[#664228]`:`text-white hover:bg-white hover:text-[#664228]`} items-center`} href="/turnover">
<path d="M7.75 15V11.5H6.25V7.9C6.25 7.51667 6.5125 7.1875 7.0375 6.9125C7.5625 6.6375 8.21667 6.5 9 6.5C9.78333 6.5 10.4375 6.6375 10.9625 6.9125C11.4875 7.1875 11.75 7.51667 11.75 7.9V11.5H10.25V15H7.75ZM9 6C8.56667 6 8.20833 5.85833 7.925 5.575C7.64167 5.29167 7.5 4.93333 7.5 4.5C7.5 4.06667 7.64167 3.70833 7.925 3.425C8.20833 3.14167 8.56667 3 9 3C9.43333 3 9.79167 3.14167 10.075 3.425C10.3583 3.70833 10.5 4.06667 10.5 4.5C10.5 4.93333 10.3583 5.29167 10.075 5.575C9.79167 5.85833 9.43333 6 9 6ZM9 18C6.5 18 4.375 17.125 2.625 15.375C0.875 13.625 0 11.5 0 9C0 7.75 0.2375 6.57917 0.7125 5.4875C1.1875 4.39583 1.82917 3.44583 2.6375 2.6375C3.44583 1.82917 4.39583 1.1875 5.4875 0.7125C6.57917 0.2375 7.75 0 9 0C10.25 0 11.4208 0.2375 12.5125 0.7125C13.6042 1.1875 14.5542 1.82917 15.3625 2.6375C16.1708 3.44583 16.8125 4.39583 17.2875 5.4875C17.7625 6.57917 18 7.75 18 9V9.2L19.825 7.35L21.25 8.75L17 13L12.75 8.75L14.175 7.35L16 9.175V9C16 7.06667 15.3167 5.41667 13.95 4.05C12.5833 2.68333 10.9333 2 9 2C7.06667 2 5.41667 2.68333 4.05 4.05C2.68333 5.41667 2 7.06667 2 9C2.01667 10.9333 2.70417 12.5833 4.0625 13.95C5.42083 15.3167 7.06667 16 9 16C9.95 16 10.8417 15.8208 11.675 15.4625C12.5083 15.1042 13.2417 14.6167 13.875 14L15.3 15.425C14.4833 16.225 13.5375 16.8542 12.4625 17.3125C11.3875 17.7708 10.2333 18 9 18Z" /> <svg width="22" height="18" viewBox="0 0 22 18" fill="none" xmlns="http://www.w3.org/2000/svg" className="fill-current">
</svg> <path d="M7.75 15V11.5H6.25V7.9C6.25 7.51667 6.5125 7.1875 7.0375 6.9125C7.5625 6.6375 8.21667 6.5 9 6.5C9.78333 6.5 10.4375 6.6375 10.9625 6.9125C11.4875 7.1875 11.75 7.51667 11.75 7.9V11.5H10.25V15H7.75ZM9 6C8.56667 6 8.20833 5.85833 7.925 5.575C7.64167 5.29167 7.5 4.93333 7.5 4.5C7.5 4.06667 7.64167 3.70833 7.925 3.425C8.20833 3.14167 8.56667 3 9 3C9.43333 3 9.79167 3.14167 10.075 3.425C10.3583 3.70833 10.5 4.06667 10.5 4.5C10.5 4.93333 10.3583 5.29167 10.075 5.575C9.79167 5.85833 9.43333 6 9 6ZM9 18C6.5 18 4.375 17.125 2.625 15.375C0.875 13.625 0 11.5 0 9C0 7.75 0.2375 6.57917 0.7125 5.4875C1.1875 4.39583 1.82917 3.44583 2.6375 2.6375C3.44583 1.82917 4.39583 1.1875 5.4875 0.7125C6.57917 0.2375 7.75 0 9 0C10.25 0 11.4208 0.2375 12.5125 0.7125C13.6042 1.1875 14.5542 1.82917 15.3625 2.6375C16.1708 3.44583 16.8125 4.39583 17.2875 5.4875C17.7625 6.57917 18 7.75 18 9V9.2L19.825 7.35L21.25 8.75L17 13L12.75 8.75L14.175 7.35L16 9.175V9C16 7.06667 15.3167 5.41667 13.95 4.05C12.5833 2.68333 10.9333 2 9 2C7.06667 2 5.41667 2.68333 4.05 4.05C2.68333 5.41667 2 7.06667 2 9C2.01667 10.9333 2.70417 12.5833 4.0625 13.95C5.42083 15.3167 7.06667 16 9 16C9.95 16 10.8417 15.8208 11.675 15.4625C12.5083 15.1042 13.2417 14.6167 13.875 14L15.3 15.425C14.4833 16.225 13.5375 16.8542 12.4625 17.3125C11.3875 17.7708 10.2333 18 9 18Z" />
<span className="font-semibold text-base">Turn Over Rate</span> </svg>
</Link> <span className="font-semibold text-base">Turn Over Rate</span>
<Link className={`flex gap-4 px-8 py-4 ${pathname === '/produktifitas' ? `bg-white text-[#664228]`:`text-white hover:bg-white hover:text-[#664228]`} items-center`} href="/produktifitas"> </Link>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className="fill-current"> <Link className={`flex gap-4 px-8 py-4 ${pathname === '/produktifitas' ? `bg-white text-[#664228]`:`text-white hover:bg-white hover:text-[#664228]`} items-center`} href="/produktifitas">
<path d="M0 16V8H4V16H0ZM6 16V0H10V16H6ZM12 16V5H16V16H12Z" /> <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" className="fill-current">
</svg> <path d="M0 16V8H4V16H0ZM6 16V0H10V16H6ZM12 16V5H16V16H12Z" />
<span className="font-semibold text-base">Produktifitas Karyawan</span> </svg>
</Link> <span className="font-semibold text-base">Produktifitas Karyawan</span>
<Link className={`flex gap-4 px-8 py-4 ${pathname === '/hr-cost' ? `bg-white text-[#664228]`:`text-white hover:bg-white hover:text-[#664228]`} items-center`} href="/hr-cost"> </Link>
<svg width="22" height="16" viewBox="0 0 22 16" fill="none" xmlns="http://www.w3.org/2000/svg" className="fill-current"> <Link className={`flex gap-4 px-8 py-4 ${pathname === '/hr-cost' ? `bg-white text-[#664228]`:`text-white hover:bg-white hover:text-[#664228]`} items-center`} href="/hr-cost">
<path d="M13 9C12.1667 9 11.4583 8.70833 10.875 8.125C10.2917 7.54167 10 6.83333 10 6C10 5.16667 10.2917 4.45833 10.875 3.875C11.4583 3.29167 12.1667 3 13 3C13.8333 3 14.5417 3.29167 15.125 3.875C15.7083 4.45833 16 5.16667 16 6C16 6.83333 15.7083 7.54167 15.125 8.125C14.5417 8.70833 13.8333 9 13 9ZM6 12C5.45 12 4.97917 11.8042 4.5875 11.4125C4.19583 11.0208 4 10.55 4 10V2C4 1.45 4.19583 0.979167 4.5875 0.5875C4.97917 0.195833 5.45 0 6 0H20C20.55 0 21.0208 0.195833 21.4125 0.5875C21.8042 0.979167 22 1.45 22 2V10C22 10.55 21.8042 11.0208 21.4125 11.4125C21.0208 11.8042 20.55 12 20 12H6ZM8 10H18C18 9.45 18.1958 8.97917 18.5875 8.5875C18.9792 8.19583 19.45 8 20 8V4C19.45 4 18.9792 3.80417 18.5875 3.4125C18.1958 3.02083 18 2.55 18 2H8C8 2.55 7.80417 3.02083 7.4125 3.4125C7.02083 3.80417 6.55 4 6 4V8C6.55 8 7.02083 8.19583 7.4125 8.5875C7.80417 8.97917 8 9.45 8 10ZM19 16H2C1.45 16 0.979167 15.8042 0.5875 15.4125C0.195833 15.0208 0 14.55 0 14V3H2V14H19V16Z"/> <svg width="22" height="16" viewBox="0 0 22 16" fill="none" xmlns="http://www.w3.org/2000/svg" className="fill-current">
</svg> <path d="M13 9C12.1667 9 11.4583 8.70833 10.875 8.125C10.2917 7.54167 10 6.83333 10 6C10 5.16667 10.2917 4.45833 10.875 3.875C11.4583 3.29167 12.1667 3 13 3C13.8333 3 14.5417 3.29167 15.125 3.875C15.7083 4.45833 16 5.16667 16 6C16 6.83333 15.7083 7.54167 15.125 8.125C14.5417 8.70833 13.8333 9 13 9ZM6 12C5.45 12 4.97917 11.8042 4.5875 11.4125C4.19583 11.0208 4 10.55 4 10V2C4 1.45 4.19583 0.979167 4.5875 0.5875C4.97917 0.195833 5.45 0 6 0H20C20.55 0 21.0208 0.195833 21.4125 0.5875C21.8042 0.979167 22 1.45 22 2V10C22 10.55 21.8042 11.0208 21.4125 11.4125C21.0208 11.8042 20.55 12 20 12H6ZM8 10H18C18 9.45 18.1958 8.97917 18.5875 8.5875C18.9792 8.19583 19.45 8 20 8V4C19.45 4 18.9792 3.80417 18.5875 3.4125C18.1958 3.02083 18 2.55 18 2H8C8 2.55 7.80417 3.02083 7.4125 3.4125C7.02083 3.80417 6.55 4 6 4V8C6.55 8 7.02083 8.19583 7.4125 8.5875C7.80417 8.97917 8 9.45 8 10ZM19 16H2C1.45 16 0.979167 15.8042 0.5875 15.4125C0.195833 15.0208 0 14.55 0 14V3H2V14H19V16Z"/>
<span className="font-semibold text-base">HR Cost</span> </svg>
</Link> <span className="font-semibold text-base">HR Cost</span>
<Link className="mt-auto flex text-white gap-4 px-8 py-4 hover:bg-white hover:text-[#664228] items-center " href="/dashboard/hr-cost"> </Link>
<svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg" className="fill-current"> <Link className="mt-auto flex text-white gap-4 px-8 py-4 hover:bg-white hover:text-[#664228] items-center " href="/logout">
<path d="M2 18.2554C1.45 18.2554 0.979167 18.0595 0.5875 17.6679C0.195833 17.2762 0 16.8054 0 16.2554V2.25537C0 1.70537 0.195833 1.23454 0.5875 0.842871C0.979167 0.451204 1.45 0.255371 2 0.255371H9V2.25537H2V16.2554H9V18.2554H2ZM13 14.2554L11.625 12.8054L14.175 10.2554H6V8.25537H14.175L11.625 5.70537L13 4.25537L18 9.25537L13 14.2554Z"/> <svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg" className="fill-current">
</svg> <path d="M2 18.2554C1.45 18.2554 0.979167 18.0595 0.5875 17.6679C0.195833 17.2762 0 16.8054 0 16.2554V2.25537C0 1.70537 0.195833 1.23454 0.5875 0.842871C0.979167 0.451204 1.45 0.255371 2 0.255371H9V2.25537H2V16.2554H9V18.2554H2ZM13 14.2554L11.625 12.8054L14.175 10.2554H6V8.25537H14.175L11.625 5.70537L13 4.25537L18 9.25537L13 14.2554Z"/>
<span className="font-semibold text-base">Log Out</span> </svg>
</Link> <span className="font-semibold text-base">Log Out</span>
<div className="flex gap-4 px-8 py-4 mt-4 items-center"> </Link>
<span className="text-[12px]">Powered by</span> <div className="flex gap-4 px-8 py-4 mt-4 items-center">
<div> <span className="text-[12px]">Powered by</span>
<Image src={midsuit} alt="midsuit" width={80} height={20}/> <div>
<Image src={midsuit} alt="midsuit" width={80} height={20}/>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> <div className="w-full flex flex-col h-screen">
<div className="w-full flex flex-col h-screen"> <div className="flex bg-white p-4 justify-between">
<div className="flex bg-white p-4 justify-between"> <div className="flex gap-1 items-center">
<div className="flex gap-1 items-center"> <span>ID</span>
<span>ID</span> <div className="w-0.5 bg-amber-800 h-4"/>
<div className="w-0.5 bg-amber-800 h-4"/> <span>CN</span>
<span>CN</span> </div>
</div> <div className="flex gap-4 ml-12">
<div className="flex gap-4 ml-12"> <DatePicker label="Start Date" className="w-40"
<DatePicker label="Start Date" className="w-40" slotProps={{
slotProps={{ textField: {
textField: { size: "small"
size: "small" }
} }}
}} defaultValue={(new Date(new Date().getFullYear(), new Date().getMonth(), 1))}
defaultValue={(new Date(new Date().getFullYear(), new Date().getMonth(), 1))} onChange={(date) => dispatch(setFilter({...filter, start_date: format(date ?? new Date(), "yyyy-MM-dd")}))}
onChange={(date) => dispatch(setFilter({...filter, start_date: format(date ?? new Date(), "yyyy-MM-dd")}))} />
/> <DatePicker label="End Date" className="w-40"
<DatePicker label="End Date" className="w-40" slotProps={{
slotProps={{ textField: {
textField: { size: "small",
size: "small", }
} }}
}} defaultValue={new Date()}
defaultValue={new Date()} onChange={(date) => dispatch(setFilter({...filter, end_date: format(date ?? new Date(), "yyyy-MM-dd")}))}
onChange={(date) => dispatch(setFilter({...filter, end_date: format(date ?? new Date(), "yyyy-MM-dd")}))} />
/> </div>
</div> <div className="flex gap-4 w-full mx-12">
<div className="flex gap-4 w-full mx-12"> <Autocomplete
<Autocomplete
className="w-full"
options={filterOptions?.regions ?? []}
getOptionLabel={(option) => option.name}
renderInput={(params) => <TextField {...params} label="Region" size="small"/>}
onChange={(e, value) => {
dispatch(setFilter({...filter, organization_code: value?.codes ?? ""}));
setRegion(value?.codes ?? "");
}}
/>
<Autocomplete
className="w-full" className="w-full"
options={filterOptions?.organizations ?? []} options={filterOptions?.regions ?? []}
getOptionLabel={(option) => option.name} getOptionLabel={(option) => option.name}
renderInput={(params) => <TextField {...params} label="Company" size="small"/>} renderInput={(params) => <TextField {...params} label="Region" size="small"/>}
onChange={(e, value) => { onChange={(e, value) => {
dispatch(setFilter({...filter, organization_code: value?.code ?? ""})); dispatch(setFilter({...filter, organization_code: value?.codes ?? ""}));
if (value?.code == undefined) dispatch(setFilter({...filter, organization_code: region})); setRegion(value?.codes ?? "");
}} }}
/> />
<Autocomplete <Autocomplete
className="w-full" className="w-full"
options={filterOptions?.estates ?? []} options={filterOptions?.organizations ?? []}
getOptionLabel={(option) => option.name} getOptionLabel={(option) => option.name}
renderInput={(params) => <TextField {...params} label="Lokasi" size="small"/>} renderInput={(params) => <TextField {...params} label="Company" size="small"/>}
onChange={(e, value) => dispatch(setFilter({...filter, estate_name: value?.name ?? ""}))} onChange={(e, value) => {
/> dispatch(setFilter({...filter, organization_code: value?.code ?? ""}));
if (value?.code == undefined) dispatch(setFilter({...filter, organization_code: region}));
}}
/>
<Autocomplete
className="w-full"
options={filterOptions?.estates ?? []}
getOptionLabel={(option) => option.name}
renderInput={(params) => <TextField {...params} label="Lokasi" size="small"/>}
onChange={(e, value) => dispatch(setFilter({...filter, estate_name: value?.name ?? ""}))}
/>
</div>
<div className="flex gap-4">
<TextField label="Posisi" size="small" className="w-40" select value={filter.job_name} onChange={(e) => dispatch(setFilter({...filter, job_name: e.target.value}))}>
<MenuItem value="ALL">Semua</MenuItem>
<MenuItem value="STAFF">Staff</MenuItem>
<MenuItem value="NON-STAFF">Non Staff</MenuItem>
<MenuItem value="HARVESTERS">Pemanen</MenuItem>
<MenuItem value="MAINTENANCE">Perawatan</MenuItem>
</TextField>
</div>
</div> </div>
<div className="flex gap-4"> <div className="bg-[#f0f0f0] h-full p-4 overflow-y-auto">
<TextField label="Posisi" size="small" className="w-40" select value={filter.job_name} onChange={(e) => dispatch(setFilter({...filter, job_name: e.target.value}))}> {children}
<MenuItem value="ALL">Semua</MenuItem>
<MenuItem value="STAFF">Staff</MenuItem>
<MenuItem value="NON-STAFF">Non Staff</MenuItem>
<MenuItem value="HARVESTERS">Pemanen</MenuItem>
<MenuItem value="MAINTENANCE">Perawatan</MenuItem>
</TextField>
</div> </div>
</div> </div>
<div className="bg-[#f0f0f0] h-full p-4 overflow-y-auto">
{children}
</div>
</div> </div>
</div> </NeedLoginRoute>
) )
} }

View File

@ -1,23 +1,23 @@
"use client"; "use client";
import { LocalizationProvider } from "@mui/x-date-pickers"; import { LocalizationProvider } from "@mui/x-date-pickers";
import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFnsV3"; import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFnsV3";
import { makeStore, AppStore } from '../lib/store' import { AppStore, persistor, store } from '../lib/store'
import { useRef } from "react"; import { useRef } from "react";
import { Provider } from "react-redux"; import { Provider } from "react-redux";
import { StyledEngineProvider } from "@mui/material"; import { StyledEngineProvider } from "@mui/material";
import { PersistGate } from "redux-persist/integration/react";
import { GuardedRoute } from "./RouteGuard";
export default function AppProvider({children}:{children: React.ReactNode}) { export default function AppProvider({children}:{children: React.ReactNode}) {
const storeRef = useRef<AppStore>()
if (!storeRef.current) {
// Create the store instance the first time this renders
storeRef.current = makeStore()
}
return ( return (
<LocalizationProvider dateAdapter={AdapterDateFns}> <LocalizationProvider dateAdapter={AdapterDateFns}>
<Provider store={storeRef.current}> <Provider store={store}>
<StyledEngineProvider injectFirst> <PersistGate loading={null} persistor={persistor}>
{children} <StyledEngineProvider injectFirst>
</StyledEngineProvider> <GuardedRoute>
{children}
</GuardedRoute>
</StyledEngineProvider>
</PersistGate>
</Provider> </Provider>
</LocalizationProvider> </LocalizationProvider>
) )

36
src/app/RouteGuard.tsx Normal file
View File

@ -0,0 +1,36 @@
"use client";
import React from "react";
import { useAppSelector } from "@/lib/hooks";
import { usePathname, useRouter } from "next/navigation";
// set route for disabled user to come, if user has login
const IF_LOGIN = ["/login", "/register"];
export function GuardedRoute({ children }: React.PropsWithChildren) {
const router = useRouter();
const pathname = usePathname();
const auth = useAppSelector((state) => state.auth);
console.log("GuardedRoute", auth, pathname);
return children;
}
interface IGuardedRoute {
children: React.ReactNode;
role: string;
}
export function NeedLoginRoute({ children, role }: IGuardedRoute) {
const router = useRouter();
const auth = useAppSelector((state) => state.auth);
if (auth.token && auth.roles.includes(role)) {
return children;
}
console.log("NeedLoginRoute", auth, role);
router.push("/login");
return;
}

63
src/app/login/page.tsx Normal file
View File

@ -0,0 +1,63 @@
"use client"
import Image from "next/image";
import background from "../../../public/images/background.jpg";
import loginImg from "../../../public/images/logo-login.png";
import midsuit from "../../../public/images/midsuit-login.png";
import { useEffect, useState } from "react";
import { useLoginMutation } from "@/services/api";
import { useAppDispatch } from "@/lib/hooks";
import { setRoles, setToken, setUser } from "@/lib/slice/auth";
import { useRouter } from "next/navigation";
export default function LoginPage(){
const [login, {data: loginData, error: loginError}] = useLoginMutation();
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const dispatch = useAppDispatch();
const router = useRouter();
useEffect(() => {
if(loginData){
dispatch(setUser(loginData.user))
dispatch(setToken(loginData.token))
dispatch(setRoles(loginData.roles))
router.push("/karyawan")
}
}, [loginData, loginError])
return (
<div className="relative flex flex-col items-center justify-center min-h-screen bg-black">
<Image src={background} layout="fill" objectFit="cover" className="absolute z-0 opacity-50" alt=""/>
<div className="relative z-10 flex flex-col items-center justify-center w-full h-full p-4 space-y-4">
<div className="flex flex-col items-center justify-center w-full max-w-sm px-12 pb-6 space-y-8 bg-white rounded-lg">
<div className="w-full flex flex-col space-y-1">
<div className="flex items-center justify-center w-full">
<Image src={loginImg} width={150} height={150} alt=""/>
</div>
<div className="text-2xl font-bold text-[#292929] w-full">HRM Dashboard</div>
<div className="text-sm text-[#9F9F9F] text-start w-full">Masukan Username & Password</div>
</div>
<div className="flex flex-col space-y-4 w-full">
<input type="text" placeholder="Username" className="w-full px-6 py-4 border border-gray-300 rounded-lg" value={username} onChange={(e) => setUsername(e.target.value)}/>
<input type="password" placeholder="Password" className="w-full px-6 py-4 border border-gray-300 rounded-lg" value={password} onChange={(e) => setPassword(e.target.value)}/>
</div>
<div className="w-full flex flex-col space-y-1">
{loginError && <div className="text-xs text-red-500">{(loginError as any).data.message}</div>}
<button className="w-full px-6 py-4 text-white bg-[#A36A4D] rounded-lg"
onClick={async () => {
await login({username, password})
}}
>Log In</button>
<div className="flex gap-4 px-8 py-4 mt-4 items-center justify-center">
<div className="text-xs text-[#313678]">Powered by</div>
<div>
<Image src={midsuit} alt="midsuit" width={100} height={40}/>
</div>
</div>
</div>
</div>
</div>
</div>
)
}

18
src/app/logout/page.tsx Normal file
View File

@ -0,0 +1,18 @@
"use client"
import { useAppDispatch } from "@/lib/hooks";
import { setRoles, setToken, setUser } from "@/lib/slice/auth";
import { useRouter } from "next/navigation";
import { useEffect } from "react";
export default function LogoutPage() {
const dispatch = useAppDispatch();
const router = useRouter();
useEffect(() => {
dispatch(setUser(null));
dispatch(setToken(null));
dispatch(setRoles([]));
router.push("/login");
}, []);
return <div></div>
}

37
src/lib/slice/auth.ts Normal file
View File

@ -0,0 +1,37 @@
import { User } from "@/services/types";
import { createSlice } from "@reduxjs/toolkit/react";
interface AuthState {
user: User | null;
token: string | null;
roles: string[];
isLoading: boolean;
}
const initialState: AuthState = {
user: null,
token: null,
roles: [],
isLoading: false,
};
export const authSlice = createSlice({
name: "auth",
initialState,
reducers: {
setUser: (state, action) => {
state.user = action.payload;
},
setToken: (state, action) => {
state.token = action.payload;
},
setRoles: (state, action) => {
state.roles = action.payload;
},
setIsLoading: (state, action) => {
state.isLoading = action.payload;
},
},
});
export const { setUser, setToken, setRoles, setIsLoading } = authSlice.actions;

23
src/lib/storage.ts Normal file
View File

@ -0,0 +1,23 @@
"use client";
import createWebStorage from "redux-persist/lib/storage/createWebStorage";
const createNoopStorage = () => {
return {
getItem(_key: any) {
return Promise.resolve(null);
},
setItem(_key: any, value: any) {
return Promise.resolve(value);
},
removeItem(_key: any) {
return Promise.resolve();
},
};
};
const storage =
typeof window !== "undefined"
? createWebStorage("local")
: createNoopStorage();
export default storage;

View File

@ -1,20 +1,33 @@
import { api } from '@/services/api' import { api } from '@/services/api'
import { configureStore } from '@reduxjs/toolkit' import { configureStore } from '@reduxjs/toolkit'
import filterReducer from '@/lib/slice/filter' import filterReducer from '@/lib/slice/filter'
import { FLUSH, PAUSE, PERSIST, persistReducer, persistStore, PURGE, REGISTER, REHYDRATE } from "redux-persist";
import { authSlice } from './slice/auth';
import storage from './storage';
export const makeStore = () => { const persistAuth = persistReducer({
return configureStore({ key: 'auth',
reducer: { storage: storage,
[api.reducerPath]: api.reducer, }, authSlice.reducer)
filter: filterReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(api.middleware),
})
}
export const store = configureStore({
reducer: {
[api.reducerPath]: api.reducer,
auth: persistAuth,
filter: filterReducer,
},
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
}).concat(api.middleware),
})
export const persistor = persistStore(store)
// Infer the type of makeStore // Infer the type of makeStore
export type AppStore = ReturnType<typeof makeStore> export type AppStore = typeof store
// Infer the `RootState` and `AppDispatch` types from the store itself // Infer the `RootState` and `AppDispatch` types from the store itself
export type RootState = ReturnType<AppStore['getState']> export type RootState = ReturnType<AppStore['getState']>
export type AppDispatch = AppStore['dispatch'] export type AppDispatch = AppStore['dispatch']

View File

@ -1,10 +1,38 @@
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react' import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
import { AttendanceRange, AttendanceSummary, EmployeeSummary, FilterOptions, MonthlyAttendance, MonthlyEmployee, ResignationCategory, ResignationReason, ResignationType as ResignationType, ResignSummary } from './types' import { AttendanceRange, AttendanceSummary, EmployeeSummary, FilterOptions, MonthlyAttendance, MonthlyEmployee, ResignationCategory, ResignationReason, ResignationType as ResignationType, ResignSummary, User } from './types'
import { Response , Filter} from './types' import { Response , Filter} from './types'
export const api = createApi({ export const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: 'https://erp.julongindonesia.com:8443/api' }), baseQuery: fetchBaseQuery({ baseUrl: 'http://localhost:8080' }),
endpoints: (builder) => ({ endpoints: (builder) => ({
login: builder.mutation<{
user: User;
token: string;
roles: string[];
}, { username: string, password: string }>({
query: ({ username, password }) => ({
url: '/login',
method: 'POST',
body: { username, password },
}),
transformResponse: (response: Response) => {
if (response.status === "success") {
return response.data!;
}
},
}),
authCheck: builder.query<{
user: User;
token: string;
roles: string[];
}, void>({
query: () => ({ url: '/auth-check' }),
transformResponse: (response: Response) => {
if (response.status === "success") {
return response.data!;
}
},
}),
getFilterOptions: builder.query<FilterOptions, Filter>({ getFilterOptions: builder.query<FilterOptions, Filter>({
query: (params) => ({ url: '/dashboard/filter-options', params }), query: (params) => ({ url: '/dashboard/filter-options', params }),
transformResponse: (response: Response) => { transformResponse: (response: Response) => {
@ -90,4 +118,5 @@ export const api = createApi({
export const { useGetFilterOptionsQuery, useGetEmployeeSummaryQuery, useGetMonthlyEmployeeQuery, useGetMonthlyAttendanceQuery, export const { useGetFilterOptionsQuery, useGetEmployeeSummaryQuery, useGetMonthlyEmployeeQuery, useGetMonthlyAttendanceQuery,
useGetOrganizationAttendanceQuery, useGetAttendanceRangeQuery, useGetResignSummaryQuery, useGetResignTypeQuery, useGetOrganizationAttendanceQuery, useGetAttendanceRangeQuery, useGetResignSummaryQuery, useGetResignTypeQuery,
useLoginMutation, useAuthCheckQuery,
useGetResignCategoryQuery, useGetResignReasonQuery } = api useGetResignCategoryQuery, useGetResignReasonQuery } = api

View File

@ -87,3 +87,11 @@ export type ResignationReason = {
reason: string; reason: string;
count: number; count: number;
} }
export type User = {
ad_user_id: number;
name: string;
username: string;
password: string;
hc_employee_id: number;
}

View File

@ -1508,6 +1508,11 @@ readdirp@~3.6.0:
dependencies: dependencies:
picomatch "^2.2.1" picomatch "^2.2.1"
redux-persist@^6.0.0:
version "6.0.0"
resolved "https://registry.yarnpkg.com/redux-persist/-/redux-persist-6.0.0.tgz#b4d2972f9859597c130d40d4b146fecdab51b3a8"
integrity sha512-71LLMbUq2r02ng2We9S215LtPu3fY0KgaGE0k8WRgl6RkqxtGfl7HUozz1Dftwsb0D/5mZ8dwAaPbtnzfvbEwQ==
redux-thunk@^3.1.0: redux-thunk@^3.1.0:
version "3.1.0" version "3.1.0"
resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-3.1.0.tgz#94aa6e04977c30e14e892eae84978c1af6058ff3" resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-3.1.0.tgz#94aa6e04977c30e14e892eae84978c1af6058ff3"