webapp
Some checks failed
Docker Build and Publish / publish (push) Failing after 1m33s

This commit is contained in:
Eugene Howe
2026-02-17 09:47:30 -05:00
parent af09672ee3
commit b0957bfa49
102 changed files with 4213 additions and 378 deletions

1
frontend/.env Symbolic link
View File

@@ -0,0 +1 @@
../.env

24
frontend/.gitignore vendored Normal file
View 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
View 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
View 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
View 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
View 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
View 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

View 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;
};
};
};
};
};
}

View File

@@ -0,0 +1,3 @@
export * from "./request";
export * from "./users";
export * from "./process";

View 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
View 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
View 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");
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View 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>
);
};

View 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}</>;
};

View 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
);
};

View File

@@ -0,0 +1 @@
export * from "./SidePanel";

View File

@@ -0,0 +1 @@
export const SIDEPANEL_ANIMATION_TIME = 400 as const;

View File

@@ -0,0 +1,3 @@
export * from "./Navbar";
export * from "./ProtectedWrapper";
export * from "./SidePanel";

View File

@@ -0,0 +1,2 @@
export * from "./useUser";
export * from "./usePortal";

View 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,
};
};

View 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
View 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
View 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>
);

View 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>()

View 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 />
</>
),
});

View 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 &gt; Employees &gt; Employee Reports &gt; 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

31
frontend/tsconfig.json Normal file
View 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" }]
}

View 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
View 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",
},
});