done
This commit is contained in:
parent
cc51d06c97
commit
8ef2970c25
|
@ -22,7 +22,8 @@
|
|||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"react-redux": "^9.1.2",
|
||||
"react-tooltip": "^5.28.0"
|
||||
"react-tooltip": "^5.28.0",
|
||||
"redux-persist": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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 |
|
@ -2,7 +2,7 @@
|
|||
import { useAppSelector } from "@/lib/hooks";
|
||||
import { useGetAttendanceRangeQuery, useGetMonthlyAttendanceQuery, useGetOrganizationAttendanceQuery } from "@/services/api";
|
||||
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 { 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="text-xl font-bold">Data Kehadiran Karyawan Setiap Perusahaan</div>
|
||||
<div className="flex-1 min-h-[300px]">
|
||||
{attendanceSummary && <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: "absent", label: (v) => v === "tooltip" ? "Absent" : undefined!, color: "#F7B500", stack: "attendance", stackOffset: "expand", valueFormatter: (v) => v + " mandays"},
|
||||
<BarChart className="w-full" dataset={attendanceSummary ?? []} series={[
|
||||
{dataKey: "count", label: (v) => undefined!, color: "#2385DE", stack: "attendance", stackOffset: "expand", valueFormatter: (v) => undefined!},
|
||||
{dataKey: "absent", label: (v) => undefined!, color: "#F7B500", stack: "attendance", stackOffset: "expand", valueFormatter: (v) => undefined!},
|
||||
]} 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={[
|
||||
{dataKey: "count",scaleType: "linear", valueFormatter: (v) => (v*100)+"%"}
|
||||
]}
|
||||
tooltip={{
|
||||
trigger: "axis",
|
||||
}}
|
||||
slots={{
|
||||
noDataOverlay: ()=> <ChartsNoDataOverlay message="Data belum tersedia" />,
|
||||
}}
|
||||
/>}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-4 bg-white rounded-lg py-4 pl-4 max-h-[420px] flex flex-col">
|
||||
|
|
|
@ -14,6 +14,7 @@ import { useAppDispatch, useAppSelector } from "@/lib/hooks";
|
|||
import { setFilter } from "@/lib/slice/filter";
|
||||
import { format } from "date-fns";
|
||||
import { useGetFilterOptionsQuery } from "@/services/api";
|
||||
import { NeedLoginRoute } from "../RouteGuard";
|
||||
|
||||
|
||||
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("");
|
||||
|
||||
return (
|
||||
<NeedLoginRoute role="Dashboard">
|
||||
<div className="flex min-h-screen">
|
||||
<div className="w-80 flex flex-col sticky h-screen">
|
||||
<div className="w-80">
|
||||
|
@ -63,7 +65,7 @@ export default function DashboardLayout({children}:{children: React.ReactNode})
|
|||
</svg>
|
||||
<span className="font-semibold text-base">HR Cost</span>
|
||||
</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">
|
||||
<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>
|
||||
|
@ -148,5 +150,6 @@ export default function DashboardLayout({children}:{children: React.ReactNode})
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NeedLoginRoute>
|
||||
)
|
||||
}
|
|
@ -1,23 +1,23 @@
|
|||
"use client";
|
||||
import { LocalizationProvider } from "@mui/x-date-pickers";
|
||||
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 { Provider } from "react-redux";
|
||||
import { StyledEngineProvider } from "@mui/material";
|
||||
import { PersistGate } from "redux-persist/integration/react";
|
||||
import { GuardedRoute } from "./RouteGuard";
|
||||
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 (
|
||||
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
||||
<Provider store={storeRef.current}>
|
||||
<Provider store={store}>
|
||||
<PersistGate loading={null} persistor={persistor}>
|
||||
<StyledEngineProvider injectFirst>
|
||||
<GuardedRoute>
|
||||
{children}
|
||||
</GuardedRoute>
|
||||
</StyledEngineProvider>
|
||||
</PersistGate>
|
||||
</Provider>
|
||||
</LocalizationProvider>
|
||||
)
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
||||
)
|
||||
}
|
|
@ -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>
|
||||
}
|
|
@ -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;
|
|
@ -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;
|
|
@ -1,20 +1,33 @@
|
|||
import { api } from '@/services/api'
|
||||
import { configureStore } from '@reduxjs/toolkit'
|
||||
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 = () => {
|
||||
return configureStore({
|
||||
const persistAuth = persistReducer({
|
||||
key: 'auth',
|
||||
storage: storage,
|
||||
}, authSlice.reducer)
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
[api.reducerPath]: api.reducer,
|
||||
auth: persistAuth,
|
||||
filter: filterReducer,
|
||||
},
|
||||
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
|
||||
export type AppStore = ReturnType<typeof makeStore>
|
||||
export type AppStore = typeof store
|
||||
// Infer the `RootState` and `AppDispatch` types from the store itself
|
||||
export type RootState = ReturnType<AppStore['getState']>
|
||||
export type AppDispatch = AppStore['dispatch']
|
|
@ -1,10 +1,38 @@
|
|||
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'
|
||||
|
||||
export const api = createApi({
|
||||
baseQuery: fetchBaseQuery({ baseUrl: 'https://erp.julongindonesia.com:8443/api' }),
|
||||
baseQuery: fetchBaseQuery({ baseUrl: 'http://localhost:8080' }),
|
||||
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>({
|
||||
query: (params) => ({ url: '/dashboard/filter-options', params }),
|
||||
transformResponse: (response: Response) => {
|
||||
|
@ -90,4 +118,5 @@ export const api = createApi({
|
|||
|
||||
export const { useGetFilterOptionsQuery, useGetEmployeeSummaryQuery, useGetMonthlyEmployeeQuery, useGetMonthlyAttendanceQuery,
|
||||
useGetOrganizationAttendanceQuery, useGetAttendanceRangeQuery, useGetResignSummaryQuery, useGetResignTypeQuery,
|
||||
useLoginMutation, useAuthCheckQuery,
|
||||
useGetResignCategoryQuery, useGetResignReasonQuery } = api
|
|
@ -87,3 +87,11 @@ export type ResignationReason = {
|
|||
reason: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export type User = {
|
||||
ad_user_id: number;
|
||||
name: string;
|
||||
username: string;
|
||||
password: string;
|
||||
hc_employee_id: number;
|
||||
}
|
|
@ -1508,6 +1508,11 @@ readdirp@~3.6.0:
|
|||
dependencies:
|
||||
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:
|
||||
version "3.1.0"
|
||||
resolved "https://registry.yarnpkg.com/redux-thunk/-/redux-thunk-3.1.0.tgz#94aa6e04977c30e14e892eae84978c1af6058ff3"
|
||||
|
|
Loading…
Reference in New Issue