diff --git a/package.json b/package.json index 3d1fe52..4ddb8a5 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/public/images/background.jpg b/public/images/background.jpg new file mode 100644 index 0000000..9067154 Binary files /dev/null and b/public/images/background.jpg differ diff --git a/public/images/logo-login.png b/public/images/logo-login.png new file mode 100644 index 0000000..c98ecb3 Binary files /dev/null and b/public/images/logo-login.png differ diff --git a/public/images/midsuit-login.png b/public/images/midsuit-login.png new file mode 100644 index 0000000..692cb8c Binary files /dev/null and b/public/images/midsuit-login.png differ diff --git a/src/app/(dashboard)/absensi/page.tsx b/src/app/(dashboard)/absensi/page.tsx index 059f7d7..039789c 100644 --- a/src/app/(dashboard)/absensi/page.tsx +++ b/src/app/(dashboard)/absensi/page.tsx @@ -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() {
Data Kehadiran Karyawan Setiap Perusahaan
- {attendanceSummary && 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"}, + 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: ()=> , }} - />} + />
diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index ed9b6ad..a41559a 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -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,128 +26,130 @@ export default function DashboardLayout({children}:{children: React.ReactNode}) const [region, setRegion] = React.useState(""); return ( -
-
-
- logo -
-
-
-
Halaman
- - - - - Data Karyawan - - - - - - Absensi - - - - - - Turn Over Rate - - - - - - Produktifitas Karyawan - - - - - - HR Cost - - - - - - Log Out - -
- Powered by -
- midsuit + +
+
+
+ logo +
+
+
+
Halaman
+ + + + + Data Karyawan + + + + + + Absensi + + + + + + Turn Over Rate + + + + + + Produktifitas Karyawan + + + + + + HR Cost + + + + + + Log Out + +
+ Powered by +
+ midsuit +
-
-
-
-
- ID -
- CN -
-
- dispatch(setFilter({...filter, start_date: format(date ?? new Date(), "yyyy-MM-dd")}))} - /> - dispatch(setFilter({...filter, end_date: format(date ?? new Date(), "yyyy-MM-dd")}))} - /> -
-
- option.name} - renderInput={(params) => } - onChange={(e, value) => { - dispatch(setFilter({...filter, organization_code: value?.codes ?? ""})); - setRegion(value?.codes ?? ""); - }} - /> - +
+
+ ID +
+ CN +
+
+ dispatch(setFilter({...filter, start_date: format(date ?? new Date(), "yyyy-MM-dd")}))} + /> + dispatch(setFilter({...filter, end_date: format(date ?? new Date(), "yyyy-MM-dd")}))} + /> +
+
+ option.name} - renderInput={(params) => } - onChange={(e, value) => { - dispatch(setFilter({...filter, organization_code: value?.code ?? ""})); - if (value?.code == undefined) dispatch(setFilter({...filter, organization_code: region})); - }} - /> - option.name} - renderInput={(params) => } - onChange={(e, value) => dispatch(setFilter({...filter, estate_name: value?.name ?? ""}))} - /> + options={filterOptions?.regions ?? []} + getOptionLabel={(option) => option.name} + renderInput={(params) => } + onChange={(e, value) => { + dispatch(setFilter({...filter, organization_code: value?.codes ?? ""})); + setRegion(value?.codes ?? ""); + }} + /> + option.name} + renderInput={(params) => } + onChange={(e, value) => { + dispatch(setFilter({...filter, organization_code: value?.code ?? ""})); + if (value?.code == undefined) dispatch(setFilter({...filter, organization_code: region})); + }} + /> + option.name} + renderInput={(params) => } + onChange={(e, value) => dispatch(setFilter({...filter, estate_name: value?.name ?? ""}))} + /> +
+
+ dispatch(setFilter({...filter, job_name: e.target.value}))}> + Semua + Staff + Non Staff + Pemanen + Perawatan + +
-
- dispatch(setFilter({...filter, job_name: e.target.value}))}> - Semua - Staff - Non Staff - Pemanen - Perawatan - +
+ {children}
-
- {children} -
-
+ ) } \ No newline at end of file diff --git a/src/app/AppProvider.tsx b/src/app/AppProvider.tsx index 9ae6a15..517601f 100644 --- a/src/app/AppProvider.tsx +++ b/src/app/AppProvider.tsx @@ -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() - if (!storeRef.current) { - // Create the store instance the first time this renders - storeRef.current = makeStore() - } - return ( - - - {children} - + + + + + {children} + + + ) diff --git a/src/app/RouteGuard.tsx b/src/app/RouteGuard.tsx new file mode 100644 index 0000000..5f2cc92 --- /dev/null +++ b/src/app/RouteGuard.tsx @@ -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; +} \ No newline at end of file diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..9d90a0c --- /dev/null +++ b/src/app/login/page.tsx @@ -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 ( +
+ +
+
+
+
+ +
+
HRM Dashboard
+
Masukan Username & Password
+
+
+ setUsername(e.target.value)}/> + setPassword(e.target.value)}/> +
+
+ {loginError &&
{(loginError as any).data.message}
} + +
+
Powered by
+
+ midsuit +
+
+
+
+
+
+ ) +} \ No newline at end of file diff --git a/src/app/logout/page.tsx b/src/app/logout/page.tsx new file mode 100644 index 0000000..7949cd5 --- /dev/null +++ b/src/app/logout/page.tsx @@ -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
+} \ No newline at end of file diff --git a/src/lib/slice/auth.ts b/src/lib/slice/auth.ts new file mode 100644 index 0000000..23d5f74 --- /dev/null +++ b/src/lib/slice/auth.ts @@ -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; \ No newline at end of file diff --git a/src/lib/storage.ts b/src/lib/storage.ts new file mode 100644 index 0000000..354474b --- /dev/null +++ b/src/lib/storage.ts @@ -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; \ No newline at end of file diff --git a/src/lib/store.ts b/src/lib/store.ts index 3de5a2f..c59e66e 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -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({ - reducer: { - [api.reducerPath]: api.reducer, - filter: filterReducer, - }, - middleware: (getDefaultMiddleware) => - getDefaultMiddleware().concat(api.middleware), - }) -} +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({ + 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 +export type AppStore = typeof store // Infer the `RootState` and `AppDispatch` types from the store itself export type RootState = ReturnType export type AppDispatch = AppStore['dispatch'] \ No newline at end of file diff --git a/src/services/api.ts b/src/services/api.ts index 6bff3bb..8e8b86b 100644 --- a/src/services/api.ts +++ b/src/services/api.ts @@ -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({ 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 \ No newline at end of file diff --git a/src/services/types.ts b/src/services/types.ts index 708259d..98a8535 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -86,4 +86,12 @@ export type ResignationCategory = { export type ResignationReason = { reason: string; count: number; +} + +export type User = { + ad_user_id: number; + name: string; + username: string; + password: string; + hc_employee_id: number; } \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index b1695f5..854da73 100644 --- a/yarn.lock +++ b/yarn.lock @@ -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"