This commit is contained in:
1
frontend/.env
Symbolic link
1
frontend/.env
Symbolic link
@@ -0,0 +1 @@
|
||||
../.env
|
||||
24
frontend/.gitignore
vendored
Normal file
24
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
54
frontend/README.md
Normal file
54
frontend/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default tseslint.config({
|
||||
extends: [
|
||||
// Remove ...tseslint.configs.recommended and replace with this
|
||||
...tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
...tseslint.configs.stylisticTypeChecked,
|
||||
],
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default tseslint.config({
|
||||
plugins: {
|
||||
// Add the react-x and react-dom plugins
|
||||
'react-x': reactX,
|
||||
'react-dom': reactDom,
|
||||
},
|
||||
rules: {
|
||||
// other rules...
|
||||
// Enable its recommended typescript rules
|
||||
...reactX.configs['recommended-typescript'].rules,
|
||||
...reactDom.configs.recommended.rules,
|
||||
},
|
||||
})
|
||||
```
|
||||
28
frontend/eslint.config.js
Normal file
28
frontend/eslint.config.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
12
frontend/index.html
Normal file
12
frontend/index.html
Normal file
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
59
frontend/package.json
Normal file
59
frontend/package.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"name": "calculate_negative_points",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "bunx --bun vite",
|
||||
"build": "bunx --bun vite build --emptyOutDir",
|
||||
"lint": "bunx --bun eslint .",
|
||||
"typecheck": "bunx --bun tsc --noEmit",
|
||||
"generate-api-types": "bunx --bun openapi-typescript ../docs/openapi/api.yaml --output ./src/api/generated/schema.ts",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-form": "^1.28.0",
|
||||
"@tanstack/react-query": "^5.79.2",
|
||||
"@tanstack/react-router": "^1.120.12",
|
||||
"@types/lodash": "^4.17.17",
|
||||
"axios": "^1.9.0",
|
||||
"bootstrap": "^5.3.6",
|
||||
"classnames": "^2.5.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"flatpickr": "^4.6.13",
|
||||
"framer-motion": "^12.16.0",
|
||||
"js-cookie": "^3.0.5",
|
||||
"lodash": "^4.17.21",
|
||||
"oidc-client-ts": "^3.2.1",
|
||||
"pluralize": "^8.0.0",
|
||||
"react": "^19.1.0",
|
||||
"react-bootstrap-date-picker": "^5.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-flatpickr": "^4.0.10",
|
||||
"react-oidc-context": "^3.3.0",
|
||||
"reactstrap": "^9.2.3",
|
||||
"sass": "^1.89.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.25.0",
|
||||
"@rollup/plugin-image": "^3.0.3",
|
||||
"@tanstack/react-router-devtools": "^1.120.12",
|
||||
"@tanstack/router-plugin": "^1.120.12",
|
||||
"@types/classnames": "^2.3.4",
|
||||
"@types/flatpickr": "^3.1.4",
|
||||
"@types/js-cookie": "^3.0.6",
|
||||
"@types/pluralize": "^0.0.33",
|
||||
"@types/react": "^19.1.2",
|
||||
"@types/react-bootstrap-date-picker": "^4.0.12",
|
||||
"@types/react-dom": "^19.1.2",
|
||||
"@vitejs/plugin-react-swc": "^3.9.0",
|
||||
"eslint": "^9.25.0",
|
||||
"eslint-plugin-react-hooks": "^5.2.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.19",
|
||||
"globals": "^16.0.0",
|
||||
"openapi-typescript": "^7.8.0",
|
||||
"typescript": "~5.8.3",
|
||||
"typescript-eslint": "^8.30.1",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
1
frontend/public/vite.svg
Normal file
1
frontend/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
150
frontend/src/api/generated/schema.ts
Normal file
150
frontend/src/api/generated/schema.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* This file was auto-generated by openapi-typescript.
|
||||
* Do not make direct changes to the file.
|
||||
*/
|
||||
|
||||
export interface paths {
|
||||
"/api/process": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Negative Points Processor
|
||||
* @description Process Negative Points
|
||||
*/
|
||||
post: operations["requests/negative_points_processor.NegativePointsProcessor"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/users/current": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* Get Current User
|
||||
* @description Retrieve the current user
|
||||
*/
|
||||
get: operations["requests/users.GetCurrentUser"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
/**
|
||||
* Logout
|
||||
* @description Logout current user
|
||||
*/
|
||||
delete: operations["requests/users.Logout"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
}
|
||||
export type webhooks = Record<string, never>;
|
||||
export interface components {
|
||||
schemas: {
|
||||
FormDataNegativePointsProcessorNegativePointsProcessorInput: {
|
||||
/** @description XLS schedule file to process */
|
||||
file?: components["schemas"]["MultipartFileHeader"];
|
||||
};
|
||||
/** Format: binary */
|
||||
MultipartFileHeader: string;
|
||||
NegativePointsProcessorNegativePointsProcessorOutput: {
|
||||
/** @description List of employees who had negative points */
|
||||
employees: string[];
|
||||
};
|
||||
TypesShowResponseClintonambulanceComCalculateNegativePointsInternalTypesUiUser: {
|
||||
item: components["schemas"]["TypesUiUser"];
|
||||
};
|
||||
TypesUiUser: {
|
||||
first_name?: string;
|
||||
groups?: string[];
|
||||
id: string;
|
||||
last_name?: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
responses: never;
|
||||
parameters: never;
|
||||
requestBodies: never;
|
||||
headers: never;
|
||||
pathItems: never;
|
||||
}
|
||||
export type $defs = Record<string, never>;
|
||||
export interface operations {
|
||||
"requests/negative_points_processor.NegativePointsProcessor": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: {
|
||||
content: {
|
||||
"multipart/form-data": components["schemas"]["FormDataNegativePointsProcessorNegativePointsProcessorInput"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["NegativePointsProcessorNegativePointsProcessorOutput"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
"requests/users.GetCurrentUser": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["TypesShowResponseClintonambulanceComCalculateNegativePointsInternalTypesUiUser"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
"requests/users.Logout": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description OK */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": null | {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
3
frontend/src/api/index.ts
Normal file
3
frontend/src/api/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./request";
|
||||
export * from "./users";
|
||||
export * from "./process";
|
||||
13
frontend/src/api/process.ts
Normal file
13
frontend/src/api/process.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import * as schema from "./generated/schema";
|
||||
import { request } from "@/api";
|
||||
|
||||
type ProcessPath = schema.paths["/api/process"];
|
||||
type ProcessResponse =
|
||||
ProcessPath["post"]["responses"]["200"]["content"]["application/json"];
|
||||
|
||||
export const processFile = async (file: File) => {
|
||||
const formData = new FormData();
|
||||
formData.append("file", file);
|
||||
|
||||
return await request.postFormData<ProcessResponse>("/api/process", formData);
|
||||
};
|
||||
121
frontend/src/api/request.ts
Normal file
121
frontend/src/api/request.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import axios, { AxiosError, AxiosPromise } from "axios";
|
||||
const jsonHeaders = (additional?: { [x: string]: string }) => ({
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json",
|
||||
...additional,
|
||||
});
|
||||
|
||||
const withUnauthorizedRedirect = async <T>(axiosFn: () => AxiosPromise<T>) => {
|
||||
try {
|
||||
const res = await axiosFn();
|
||||
|
||||
return res;
|
||||
} catch (e: unknown) {
|
||||
const status = (e as AxiosError).response?.status;
|
||||
|
||||
if (status && [401, 403].includes(status)) {
|
||||
window.location.href = `/auth/login?returnTo=${window.location.href}`;
|
||||
}
|
||||
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
|
||||
type GetRequest = { url: string } & (
|
||||
| {
|
||||
pagination?: false;
|
||||
}
|
||||
| {
|
||||
pagination: true;
|
||||
page: number;
|
||||
page_size: number;
|
||||
}
|
||||
);
|
||||
|
||||
export const request = {
|
||||
getUnauthenticated: async <T>(url: string): Promise<T> => {
|
||||
const { data } = await axios.get<T>(url, {
|
||||
headers: jsonHeaders(),
|
||||
withCredentials: true,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
get: async <T>(request: GetRequest): Promise<T> => {
|
||||
const params =
|
||||
request.pagination === true
|
||||
? { page: request.page, page_size: request.page_size }
|
||||
: undefined;
|
||||
const { data } = await withUnauthorizedRedirect(() =>
|
||||
axios.get<T>(request.url, {
|
||||
headers: jsonHeaders(),
|
||||
params,
|
||||
withCredentials: true,
|
||||
}),
|
||||
);
|
||||
return data;
|
||||
},
|
||||
put: async <T>(url: string, body: T | null): Promise<T> => {
|
||||
const { data } = await withUnauthorizedRedirect(() => {
|
||||
return axios.put<T>(url, body, {
|
||||
headers: jsonHeaders(),
|
||||
withCredentials: true,
|
||||
});
|
||||
});
|
||||
return data;
|
||||
},
|
||||
post: async <T>(url: string, body: T): Promise<T> => {
|
||||
const { data } = await withUnauthorizedRedirect(() => {
|
||||
return axios.post<T>(url, body, {
|
||||
headers: jsonHeaders(),
|
||||
withCredentials: true,
|
||||
});
|
||||
});
|
||||
return data;
|
||||
},
|
||||
delete: async <T>(url: string): Promise<T> => {
|
||||
console.log(url);
|
||||
const { data } = await withUnauthorizedRedirect(() => {
|
||||
return axios.delete<T>(url, {
|
||||
headers: jsonHeaders(),
|
||||
withCredentials: true,
|
||||
});
|
||||
});
|
||||
return data;
|
||||
},
|
||||
postFormData: async <T>(url: string, body: FormData): Promise<T> => {
|
||||
const { data } = await withUnauthorizedRedirect(() => {
|
||||
return axios.post<T>(url, body, {
|
||||
headers: { Accept: "application/json" },
|
||||
withCredentials: true,
|
||||
});
|
||||
});
|
||||
return data;
|
||||
},
|
||||
download: async <T>(url: string, body: T) => {
|
||||
const response = await withUnauthorizedRedirect(() => {
|
||||
return axios.post<Blob>(url, body, {
|
||||
responseType: "blob",
|
||||
withCredentials: true,
|
||||
});
|
||||
});
|
||||
|
||||
console.log({ response });
|
||||
|
||||
const contentDisposition = response.headers["content-disposition"];
|
||||
let filename = "";
|
||||
if (contentDisposition) {
|
||||
const match = contentDisposition.match(/filename="?([^";\n]+)"?/);
|
||||
if (match) filename = match[1];
|
||||
}
|
||||
|
||||
const blob = new Blob([response.data]);
|
||||
const urlObj = window.URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = urlObj;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
window.URL.revokeObjectURL(urlObj);
|
||||
a.remove();
|
||||
},
|
||||
};
|
||||
16
frontend/src/api/users.ts
Normal file
16
frontend/src/api/users.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import * as schema from "./generated/schema";
|
||||
import { request } from "@/api";
|
||||
type CurrentUserPath = schema.paths["/api/users/current"];
|
||||
type CurrentUserResponse =
|
||||
CurrentUserPath["get"]["responses"]["200"]["content"]["application/json"];
|
||||
export type User = CurrentUserResponse["user"];
|
||||
|
||||
export const getCurrentUser = async () => {
|
||||
return await request.getUnauthenticated<CurrentUserResponse>(
|
||||
"/api/users/current"
|
||||
);
|
||||
};
|
||||
|
||||
export const logoutUser = async () => {
|
||||
return await request.delete("/api/users/current");
|
||||
};
|
||||
BIN
frontend/src/assets/logo.png
Normal file
BIN
frontend/src/assets/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
65
frontend/src/components/Navbar.tsx
Normal file
65
frontend/src/components/Navbar.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import {
|
||||
Navbar,
|
||||
Nav,
|
||||
NavbarBrand,
|
||||
NavItem,
|
||||
NavLink,
|
||||
Button,
|
||||
UncontrolledDropdown,
|
||||
DropdownToggle,
|
||||
DropdownMenu,
|
||||
DropdownItem,
|
||||
} from "reactstrap";
|
||||
import { logoutUser } from "@/api";
|
||||
import assetUrl from "@/assets/logo.png";
|
||||
import { useRouter } from "@tanstack/react-router";
|
||||
import { useCallback } from "react";
|
||||
import { useUser } from "@/hooks";
|
||||
|
||||
export const NavbarComponent: React.FC = () => {
|
||||
const imgUrl = new URL(assetUrl, import.meta.url).href;
|
||||
const router = useRouter();
|
||||
const onLogout = useCallback(() => {
|
||||
logoutUser().then(() => router.invalidate());
|
||||
}, [router]);
|
||||
const user = useUser();
|
||||
|
||||
return (
|
||||
<Navbar color="light" expand="md">
|
||||
<Nav navbar className="align-items-center">
|
||||
<NavbarBrand href="/">
|
||||
<img src={imgUrl} alt="CAASA Logo" width="48" />
|
||||
</NavbarBrand>
|
||||
<NavItem>
|
||||
<NavLink href="/">Home</NavLink>
|
||||
</NavItem>
|
||||
</Nav>
|
||||
<Nav>
|
||||
{user ? (
|
||||
<UncontrolledDropdown
|
||||
inNavbar
|
||||
nav
|
||||
title={user.name}
|
||||
id="basic-nav-dropdown"
|
||||
>
|
||||
<DropdownToggle nav caret>
|
||||
{user.name}
|
||||
</DropdownToggle>
|
||||
<DropdownMenu right>
|
||||
<DropdownItem onClick={onLogout}>Logout</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</UncontrolledDropdown>
|
||||
) : (
|
||||
<Button
|
||||
color="link"
|
||||
onClick={() =>
|
||||
(window.location.href = `/auth/login?returnTo=${window.location.href}`)
|
||||
}
|
||||
>
|
||||
Login
|
||||
</Button>
|
||||
)}
|
||||
</Nav>
|
||||
</Navbar>
|
||||
);
|
||||
};
|
||||
20
frontend/src/components/ProtectedWrapper.tsx
Normal file
20
frontend/src/components/ProtectedWrapper.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useRoles } from "@/hooks";
|
||||
import { every } from "lodash";
|
||||
|
||||
type ProtectedWrapperProps = {
|
||||
requiredRoles: string[];
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ProtectedWrapper: React.FC<ProtectedWrapperProps> = ({
|
||||
requiredRoles,
|
||||
children,
|
||||
}) => {
|
||||
const roles = useRoles();
|
||||
|
||||
if (!every(requiredRoles, (r) => roles.includes(r))) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
};
|
||||
54
frontend/src/components/SidePanel/SidePanel.tsx
Normal file
54
frontend/src/components/SidePanel/SidePanel.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
import { AnimatePresence, motion } from "framer-motion";
|
||||
import { usePortal } from "@/hooks";
|
||||
|
||||
import { Button, Container } from "reactstrap";
|
||||
|
||||
export type SidePanelProps = {
|
||||
children: React.ReactNode;
|
||||
isVisible?: boolean;
|
||||
onClose: () => void;
|
||||
onOpen?: () => void;
|
||||
style?: { [x: string]: string };
|
||||
title: React.ReactNode;
|
||||
};
|
||||
|
||||
export const SidePanel: React.FC<SidePanelProps> = ({
|
||||
children,
|
||||
isVisible,
|
||||
onClose,
|
||||
style = {},
|
||||
title,
|
||||
}) => {
|
||||
const { portalRoot, createPortal } = usePortal();
|
||||
|
||||
return createPortal(
|
||||
<AnimatePresence>
|
||||
{isVisible && (
|
||||
<motion.aside
|
||||
className="sidepanel d-flex flex-column"
|
||||
role="complementary"
|
||||
initial={{ x: "100%" }}
|
||||
animate={{ x: "0%" }}
|
||||
exit={{ x: "100%" }}
|
||||
transition={{ duration: 0.4 }}
|
||||
key="side-panel"
|
||||
style={style}
|
||||
>
|
||||
<Container>
|
||||
<h4>{title}</h4>
|
||||
</Container>
|
||||
<hr className="w-100 flex-shrink-0" />
|
||||
|
||||
<Container className="d-flex flex-column flex-grow-1">
|
||||
{children}
|
||||
</Container>
|
||||
|
||||
<Button className="text-start" color="link" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
</motion.aside>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
portalRoot
|
||||
);
|
||||
};
|
||||
1
frontend/src/components/SidePanel/index.ts
Normal file
1
frontend/src/components/SidePanel/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from "./SidePanel";
|
||||
1
frontend/src/components/SidePanel/utils.ts
Normal file
1
frontend/src/components/SidePanel/utils.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const SIDEPANEL_ANIMATION_TIME = 400 as const;
|
||||
3
frontend/src/components/index.ts
Normal file
3
frontend/src/components/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./Navbar";
|
||||
export * from "./ProtectedWrapper";
|
||||
export * from "./SidePanel";
|
||||
2
frontend/src/hooks/index.ts
Normal file
2
frontend/src/hooks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./useUser";
|
||||
export * from "./usePortal";
|
||||
24
frontend/src/hooks/usePortal.ts
Normal file
24
frontend/src/hooks/usePortal.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { useRef, useEffect } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
export const usePortal = () => {
|
||||
const ref = useRef<HTMLDivElement>(document.createElement("div"));
|
||||
const node = ref.current;
|
||||
|
||||
useEffect(() => {
|
||||
if (!node) return;
|
||||
|
||||
node.id = "portal";
|
||||
document.body.appendChild(node);
|
||||
|
||||
return () => {
|
||||
if (!node) return;
|
||||
document.body.removeChild(node);
|
||||
};
|
||||
}, [node]);
|
||||
|
||||
return {
|
||||
portalRoot: node,
|
||||
createPortal,
|
||||
};
|
||||
};
|
||||
19
frontend/src/hooks/useUser.ts
Normal file
19
frontend/src/hooks/useUser.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Route } from "@/routes/__root";
|
||||
|
||||
export const useRoles = () => {
|
||||
const user = useUser();
|
||||
|
||||
if (!user?.groups) return [];
|
||||
|
||||
return user.groups;
|
||||
};
|
||||
|
||||
export const useUser = () => {
|
||||
const data = Route.useLoaderData();
|
||||
|
||||
if (!data) return undefined;
|
||||
|
||||
const { item } = data;
|
||||
|
||||
return item;
|
||||
};
|
||||
24
frontend/src/main.scss
Normal file
24
frontend/src/main.scss
Normal file
@@ -0,0 +1,24 @@
|
||||
aside.sidepanel {
|
||||
background-color: var(--bs-body-bg);
|
||||
bottom: 0;
|
||||
box-shadow: -20px 0 25px -5px rgba(0, 0, 0, 0.1), -10px 0 10px -5px rgba(0, 0, 0, 0.04);
|
||||
padding: 24px 20px;
|
||||
position: fixed;
|
||||
overflow: auto;
|
||||
right: 0;
|
||||
top: 74px;
|
||||
width: 100%;
|
||||
z-index: 2;
|
||||
|
||||
// Medium screens (768px+): 50% width
|
||||
@media (min-width: 768px) {
|
||||
padding: 36px 30px;
|
||||
width: 50%;
|
||||
}
|
||||
|
||||
// Large screens (992px+): 33% width
|
||||
@media (min-width: 992px) {
|
||||
padding: 48px 40px;
|
||||
width: 33%;
|
||||
}
|
||||
}
|
||||
26
frontend/src/main.tsx
Normal file
26
frontend/src/main.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { RouterProvider, createRouter } from "@tanstack/react-router";
|
||||
|
||||
import "./main.scss";
|
||||
|
||||
// Import the generated route tree
|
||||
import { routeTree } from "./routeTree.gen";
|
||||
|
||||
// Create a new router instance
|
||||
const router = createRouter({ routeTree });
|
||||
|
||||
export const API_URL = "/api" as const;
|
||||
|
||||
// Register the router instance for type safety
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router;
|
||||
}
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<RouterProvider router={router} />
|
||||
</StrictMode>
|
||||
);
|
||||
59
frontend/src/routeTree.gen.ts
Normal file
59
frontend/src/routeTree.gen.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from "./routes/__root"
|
||||
import { Route as IndexRouteImport } from "./routes/index"
|
||||
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: "/",
|
||||
path: "/",
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
"/": typeof IndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
"/": typeof IndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
"/": typeof IndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: "/"
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: "/"
|
||||
id: "__root__" | "/"
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
}
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface FileRoutesByPath {
|
||||
"/": {
|
||||
id: "/"
|
||||
path: "/"
|
||||
fullPath: "/"
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
}
|
||||
export const routeTree = rootRouteImport
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
24
frontend/src/routes/__root.tsx
Normal file
24
frontend/src/routes/__root.tsx
Normal file
@@ -0,0 +1,24 @@
|
||||
import { createRootRoute, Outlet } from "@tanstack/react-router";
|
||||
import { TanStackRouterDevtools } from "@tanstack/react-router-devtools";
|
||||
import { NavbarComponent } from "@/components";
|
||||
import { getCurrentUser } from "@/api";
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: () => {
|
||||
return (
|
||||
<>
|
||||
<NavbarComponent />
|
||||
<Outlet />
|
||||
<TanStackRouterDevtools />
|
||||
</>
|
||||
);
|
||||
},
|
||||
loader: getCurrentUser,
|
||||
errorComponent: () => (
|
||||
<>
|
||||
<NavbarComponent />
|
||||
<div>Login to continue</div>
|
||||
<TanStackRouterDevtools />
|
||||
</>
|
||||
),
|
||||
});
|
||||
101
frontend/src/routes/index.tsx
Normal file
101
frontend/src/routes/index.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { useState } from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { processFile } from "@/api/process";
|
||||
|
||||
const twoMonthsAgo = () => {
|
||||
const now = new Date();
|
||||
const target = new Date(now.getFullYear(), now.getMonth() - 2, 1);
|
||||
const end = new Date(target.getFullYear(), target.getMonth() + 1, 0);
|
||||
const month = target.toLocaleString("en-US", { month: "long" });
|
||||
return `${month} 1 - ${month} ${end.getDate()}`;
|
||||
};
|
||||
|
||||
const Index = () => {
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [status, setStatus] = useState<
|
||||
"idle" | "uploading" | "success" | "error"
|
||||
>("idle");
|
||||
const [errorMessage, setErrorMessage] = useState("");
|
||||
const [employees, setEmployees] = useState<string[]>([]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!file) return;
|
||||
|
||||
setStatus("uploading");
|
||||
setErrorMessage("");
|
||||
|
||||
try {
|
||||
const result = await processFile(file);
|
||||
setEmployees(result.employees);
|
||||
setStatus("success");
|
||||
setFile(null);
|
||||
} catch (err: unknown) {
|
||||
setStatus("error");
|
||||
setErrorMessage(
|
||||
err instanceof Error ? err.message : "Failed to process file",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<h3>Process Negative Points</h3>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="mb-3">
|
||||
<label htmlFor="file" className="form-label">
|
||||
Upload XLS Schedule File
|
||||
</label>
|
||||
<input
|
||||
id="file"
|
||||
type="file"
|
||||
className="form-control"
|
||||
accept=".xls,.xlsx"
|
||||
onChange={(e) => {
|
||||
setFile(e.target.files?.[0] ?? null);
|
||||
setStatus("idle");
|
||||
}}
|
||||
/>
|
||||
<div className="form-text">
|
||||
This file must come from ESO. To get it, go to{" "}
|
||||
<strong>
|
||||
ESO Scheduler > Employees > Employee Reports > Employee
|
||||
Hours Worked By Date Span
|
||||
</strong>
|
||||
.<br /> Select the following date range:{" "}
|
||||
<strong>{twoMonthsAgo()}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary"
|
||||
disabled={!file || status === "uploading"}
|
||||
>
|
||||
{status === "uploading" ? "Processing..." : "Upload & Process"}
|
||||
</button>
|
||||
</form>
|
||||
{status === "success" && (
|
||||
<div className="alert alert-success mt-3">
|
||||
<strong>File processed successfully.</strong>
|
||||
{employees.length > 0 && (
|
||||
<>
|
||||
<p className="mb-1 mt-2">Employees processed ({employees.length}):</p>
|
||||
<ul className="mb-0">
|
||||
{employees.map((name) => (
|
||||
<li key={name}>{name}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<div className="alert alert-danger mt-3">{errorMessage}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: Index,
|
||||
});
|
||||
1
frontend/src/vite-env.d.ts
vendored
Normal file
1
frontend/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
31
frontend/tsconfig.json
Normal file
31
frontend/tsconfig.json
Normal file
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"target": "ES2022",
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
|
||||
/* Imports */
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"@/test/*": ["./test/*"]
|
||||
}
|
||||
},
|
||||
"include": ["./src", "./test"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
frontend/tsconfig.node.json
Normal file
10
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
34
frontend/vite.config.ts
Normal file
34
frontend/vite.config.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react-swc";
|
||||
import { TanStackRouterVite } from "@tanstack/router-plugin/vite";
|
||||
import image from "@rollup/plugin-image";
|
||||
import path from "node:path";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
TanStackRouterVite({
|
||||
autoCodeSplitting: true,
|
||||
quoteStyle: "double",
|
||||
target: "react",
|
||||
}),
|
||||
image(),
|
||||
react(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
"@/test": path.resolve(__dirname, "./test"),
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
manifest: true,
|
||||
sourcemap: true,
|
||||
rollupOptions: {
|
||||
input: {
|
||||
reactApp: "./src/main.tsx",
|
||||
},
|
||||
},
|
||||
outDir: "../public",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user