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,6 +26,7 @@ export default function DashboardLayout({children}:{children: React.ReactNode})
const [region, setRegion] = React.useState(""); const [region, setRegion] = React.useState("");
return ( return (
<NeedLoginRoute role="Dashboard">
<div className="flex min-h-screen"> <div className="flex min-h-screen">
<div className="w-80 flex flex-col sticky h-screen"> <div className="w-80 flex flex-col sticky h-screen">
<div className="w-80"> <div className="w-80">
@ -63,7 +65,7 @@ export default function DashboardLayout({children}:{children: React.ReactNode})
</svg> </svg>
<span className="font-semibold text-base">HR Cost</span> <span className="font-semibold text-base">HR Cost</span>
</Link> </Link>
<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 className="mt-auto flex text-white gap-4 px-8 py-4 hover:bg-white hover:text-[#664228] items-center " href="/logout">
<svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg" className="fill-current"> <svg width="18" height="19" viewBox="0 0 18 19" fill="none" xmlns="http://www.w3.org/2000/svg" className="fill-current">
<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"/> <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> </svg>
@ -148,5 +150,6 @@ export default function DashboardLayout({children}:{children: React.ReactNode})
</div> </div>
</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}>
<PersistGate loading={null} persistor={persistor}>
<StyledEngineProvider injectFirst> <StyledEngineProvider injectFirst>
<GuardedRoute>
{children} {children}
</GuardedRoute>
</StyledEngineProvider> </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',
storage: storage,
}, authSlice.reducer)
export const store = configureStore({
reducer: { reducer: {
[api.reducerPath]: api.reducer, [api.reducerPath]: api.reducer,
auth: persistAuth,
filter: filterReducer, filter: filterReducer,
}, },
middleware: (getDefaultMiddleware) => middleware: (getDefaultMiddleware) =>
getDefaultMiddleware().concat(api.middleware), 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"