96 switch from azure table storage to sqlite (#97)
* adding typeorm to api * switching to sqlite * switching to sqlite * switching to sqlite * migrating to sqlite * updating terraform * updating infrastructure
18
client/.eslintrc.cjs
Normal file
@@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
root: true,
|
||||
env: { browser: true, es2020: true },
|
||||
extends: [
|
||||
'eslint:recommended',
|
||||
'plugin:@typescript-eslint/recommended',
|
||||
'plugin:react-hooks/recommended'
|
||||
],
|
||||
ignorePatterns: ['dist', '.eslintrc.cjs'],
|
||||
parser: '@typescript-eslint/parser',
|
||||
plugins: ['react-refresh'],
|
||||
rules: {
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true }
|
||||
]
|
||||
}
|
||||
};
|
||||
23
client/.gitignore
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# 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?
|
||||
0
client/README.md
Normal file
13
client/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/png" href="/noahspan-logo.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Flying</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
50
client/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "client",
|
||||
"private": true,
|
||||
"version": "2.0.0-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
|
||||
"preview": "vite preview",
|
||||
"serve": "serve -s dist"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fortawesome/fontawesome-svg-core": "^7.1.0",
|
||||
"@fortawesome/free-solid-svg-icons": "^7.1.0",
|
||||
"@fortawesome/react-fontawesome": "^3.1.0",
|
||||
"@noahspan/noahspan-components": "^2.0.0-alpha-14",
|
||||
"@tailwindcss/vite": "^4.1.13",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"axios": "^1.7.2",
|
||||
"daisyui": "^5.1.10",
|
||||
"dotenv": "^16.4.7",
|
||||
"leaflet": "^1.9.4",
|
||||
"oidc-spa": "^7.2.4",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-hook-form": "^7.51.4",
|
||||
"react-leaflet": "^4",
|
||||
"react-leaflet-kml": "^2.1.2",
|
||||
"react-oidc-context": "^3.3.0",
|
||||
"react-router-dom": "^6.23.0",
|
||||
"swiper": "^11.2.6",
|
||||
"tailwindcss": "^4.1.13"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@microsoft/microsoft-graph-types": "^2.40.0",
|
||||
"@types/leaflet": "^1.9.16",
|
||||
"@types/react": "^19.1.12",
|
||||
"@types/react-dom": "^19.1.8",
|
||||
"@vitejs/plugin-react": "^4.2.1",
|
||||
"typescript": "^5.2.2",
|
||||
"vite": "^5.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"files": [
|
||||
"dist"
|
||||
]
|
||||
}
|
||||
BIN
client/public/layers-2x.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
client/public/layers.png
Normal file
|
After Width: | Height: | Size: 696 B |
BIN
client/public/marker-icon-2x.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
client/public/marker-icon.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
client/public/marker-shadow.png
Normal file
|
After Width: | Height: | Size: 618 B |
BIN
client/public/noahspan-logo.png
Normal file
|
After Width: | Height: | Size: 22 KiB |
1
client/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 |
25
client/src/App.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import Flights from './components/flights/Flights';
|
||||
import Logbook from './components/logbook/Logbook';
|
||||
import Pilots from './components/pilots/Pilots';
|
||||
import SiteNav from './components/siteNav/SiteNav';
|
||||
import { HeroUIProvider } from '@heroui/react';
|
||||
import { useHref, useNavigate } from 'react-router-dom';
|
||||
import './styles.css';
|
||||
|
||||
const App = () => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<HeroUIProvider navigate={navigate} useHref={useHref}>
|
||||
<SiteNav />
|
||||
<Routes>
|
||||
<Route path='/' element={<Flights />} />
|
||||
<Route path="/logbook" element={<Logbook />} />
|
||||
<Route path="/pilots" element={<Pilots />} />
|
||||
</Routes>
|
||||
</HeroUIProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
1
client/src/assets/react.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="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
11
client/src/auth/oidcConfig.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { createReactOidc } from "oidc-spa/react";
|
||||
|
||||
export const { OidcProvider, useOidc, getOidc } = createReactOidc(async () => ({
|
||||
issuerUri: `https://login.microsoftonline.com/${import.meta.env.VITE_TENANT_ID}/v2.0`,
|
||||
clientId: import.meta.env.VITE_CLIENT_APP_ID,
|
||||
homeUrl: import.meta.env.BASE_URL,
|
||||
scopes: ['email', 'openid', 'profile', `api://${import.meta.env.VITE_API_APP_ID}/user_impersonation`],
|
||||
autoLogin: false,
|
||||
postLoginRedirectUrl: '/',
|
||||
noIframe: true
|
||||
}));
|
||||
93
client/src/components/actionMenu/ActionMenu.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import { useState } from 'react';
|
||||
import { IActionMenuProps } from './IActionMenuProps';
|
||||
import {
|
||||
IconButton,
|
||||
Icon,
|
||||
IconName,
|
||||
Dropdown
|
||||
} from '@noahspan/noahspan-components';
|
||||
import { FormMode } from '../../enums/formMode';
|
||||
import { useAuth } from 'react-oidc-context';
|
||||
|
||||
const ActionMenu = ({ id, onDelete, onOpenCloseForm, onOpenCloseTracks }: IActionMenuProps) => {
|
||||
// const [anchorElAction, setAnchorElAction] = useState<null | HTMLElement>(
|
||||
// null
|
||||
// );
|
||||
// const auth = useAuth();
|
||||
|
||||
// const onOpenActionMenu = (event: React.MouseEvent<HTMLElement>) => {
|
||||
// setAnchorElAction(event.currentTarget);
|
||||
// };
|
||||
|
||||
// const onCloseActionMenu = () => {
|
||||
// setAnchorElAction(null);
|
||||
// };
|
||||
|
||||
const options = [
|
||||
'Item 1',
|
||||
'Item 2'
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown
|
||||
onOptionSelected={() => console.log('clicked!')}
|
||||
options={options}
|
||||
>
|
||||
<IconButton>
|
||||
<Icon className='text-2xl' iconName={IconName.ELLIPSIS_VERTICAL} />
|
||||
</IconButton>
|
||||
</Dropdown>
|
||||
</>
|
||||
// <div>
|
||||
// <IconButton onClick={onOpenActionMenu}>
|
||||
// <Icon iconName={IconName.ELLIPSIS_VERTICAL} size="sm" />
|
||||
// </IconButton>
|
||||
// <Menu
|
||||
// anchorEl={anchorElAction}
|
||||
// keepMounted
|
||||
// open={Boolean(anchorElAction)}
|
||||
// onClose={onCloseActionMenu}
|
||||
// >
|
||||
// {auth.isAuthenticated &&
|
||||
// <>
|
||||
// <MenuItem onClick={() => onOpenCloseForm(FormMode.EDIT, id)}>
|
||||
// <ListItemIcon>
|
||||
// <Icon iconName={IconName.PEN} size="lg" />
|
||||
// </ListItemIcon>
|
||||
// <ListItemText>Edit</ListItemText>
|
||||
// </MenuItem>
|
||||
// {onOpenCloseTracks &&
|
||||
// <MenuItem onClick={() => onOpenCloseTracks!(FormMode.EDIT, id)}>
|
||||
// <ListItemIcon>
|
||||
// <Icon iconName={IconName.MAP_LOCATION_DOT} size="lg" />
|
||||
// </ListItemIcon>
|
||||
// <ListItemText>Tracks</ListItemText>
|
||||
// </MenuItem>
|
||||
// }
|
||||
// </>
|
||||
|
||||
// }
|
||||
// <MenuItem onClick={() => onOpenCloseForm(FormMode.VIEW, id)}>
|
||||
// <ListItemIcon>
|
||||
// <Icon iconName={IconName.EYE} size="lg" />
|
||||
// </ListItemIcon>
|
||||
// <ListItemText>View</ListItemText>
|
||||
// </MenuItem>
|
||||
// {auth.isAuthenticated &&
|
||||
// <>
|
||||
// <hr className="my-3" />
|
||||
// <MenuItem onClick={() => onDelete(id)}>
|
||||
// <ListItemIcon>
|
||||
// <Icon iconName={IconName.TRASH} size="lg" />
|
||||
// </ListItemIcon>
|
||||
// <ListItemText>Delete</ListItemText>
|
||||
// </MenuItem>
|
||||
// </>
|
||||
// }
|
||||
// </Menu>
|
||||
// </div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ActionMenu;
|
||||
8
client/src/components/actionMenu/IActionMenuProps.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { FormMode } from '../../enums/formMode';
|
||||
|
||||
export interface IActionMenuProps {
|
||||
id: string;
|
||||
onDelete: (entryId: string) => void;
|
||||
onOpenCloseForm: (formMode: FormMode, id: string) => void;
|
||||
onOpenCloseTracks?: (formMode: FormMode, id: string) => void;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// import {
|
||||
// Button,
|
||||
// Dialog,
|
||||
// DialogActions,
|
||||
// DialogContent,
|
||||
// Icon,
|
||||
// IconName,
|
||||
// Loading
|
||||
// } from '@noahspan/noahspan-components';
|
||||
import { Button, Modal, ModalBody, ModalContent, ModalHeader, ModalFooter, Spinner } from '@heroui/react'
|
||||
import { DialogConfirmationProps } from './ConfirmationDialogProps.interface';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faCircleCheck, faXmark } from '@fortawesome/free-solid-svg-icons';
|
||||
|
||||
const ConfirmationDialog = ({
|
||||
contentText,
|
||||
isLoading,
|
||||
isOpen,
|
||||
onCancel,
|
||||
onConfirm,
|
||||
title
|
||||
}: DialogConfirmationProps) => {
|
||||
return (
|
||||
<Modal
|
||||
isDismissable={false}
|
||||
isKeyboardDismissDisabled={true}
|
||||
isOpen={isOpen}
|
||||
>
|
||||
<ModalContent>
|
||||
<ModalHeader>{title}</ModalHeader>
|
||||
<ModalBody>
|
||||
{!isLoading && <div>{contentText}</div>}
|
||||
{isLoading && <Spinner size='lg' />}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button onPress={onCancel} startContent={<FontAwesomeIcon icon={faXmark} />}>
|
||||
No
|
||||
</Button>
|
||||
<Button
|
||||
color='primary'
|
||||
onPress={onConfirm}
|
||||
startContent={<FontAwesomeIcon icon={faCircleCheck} />}
|
||||
>
|
||||
Yes
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConfirmationDialog;
|
||||
@@ -0,0 +1,8 @@
|
||||
export interface DialogConfirmationProps {
|
||||
contentText: string;
|
||||
isLoading: boolean;
|
||||
isOpen: boolean;
|
||||
onCancel: () => void;
|
||||
onConfirm: () => void;
|
||||
title: string;
|
||||
}
|
||||
81
client/src/components/flights/Flights.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { Icon, IconName, Skeleton } from "@noahspan/noahspan-components";
|
||||
import { Card } from '@heroui/react';
|
||||
import LogbookCard from "../logbookCard/LogbookCard";
|
||||
import { useEffect, useReducer } from "react";
|
||||
import { useLogs } from "../../hooks/logs/UseLogs";
|
||||
import { LogbookEntry } from "../logbook/LogbookEntry.interface";
|
||||
import { initialState, reducer } from "./reducer";
|
||||
import { Alert } from '@heroui/react'
|
||||
|
||||
const Flights = () => {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const { logs, logsLoading } = useLogs();
|
||||
|
||||
useEffect(() => {
|
||||
const flights: LogbookEntry[] | undefined = logs?.filter((log: LogbookEntry) => {
|
||||
if (log.tracks && log.tracks.length > 0) {
|
||||
return log;
|
||||
}
|
||||
})
|
||||
|
||||
if (flights && flights.length > 0) {
|
||||
dispatch({ type: 'SET_FLIGHTS', payload: flights})
|
||||
dispatch({ type: 'SET_ALERT', payload: undefined })
|
||||
} else {
|
||||
dispatch({ type: 'SET_ALERT', payload: { severity: 'default', message: 'No flights found' }})
|
||||
}
|
||||
}, [logs])
|
||||
|
||||
useEffect(() => {
|
||||
console.log(logsLoading)
|
||||
}, [logsLoading])
|
||||
|
||||
return (
|
||||
<div className='max-w-screen-lg mx-auto'>
|
||||
<div className='prose mt-5 mb-5'>
|
||||
<h1>Flights</h1>
|
||||
</div>
|
||||
{!logsLoading && state.alert && (
|
||||
<div>
|
||||
<Alert
|
||||
onClose={() =>
|
||||
dispatch({ type: 'SET_ALERT', payload: undefined })
|
||||
}
|
||||
color={state.alert.severity}
|
||||
title={state.alert.message}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!logsLoading &&
|
||||
<div>
|
||||
<LogbookCard logs={state.flights} mode='flights' />
|
||||
</div>
|
||||
}
|
||||
{logsLoading && [...Array(6)].map((_element, index) => {
|
||||
return (
|
||||
<div className='mb-5'>
|
||||
<Card
|
||||
key={index}
|
||||
>
|
||||
<div className='p-5'>
|
||||
<div className='mb-2'><Skeleton height='h-[60px]' width='w-[200px]' /></div>
|
||||
<div className='mb-2'><Skeleton height='h-[30px]' width='w-[200px]' /></div>
|
||||
<div className='mb-2'><Skeleton height='h-[300px]' width='w-[300px]' /></div>
|
||||
{[...Array(4)].map((_element, index) => {
|
||||
return (
|
||||
<>
|
||||
<div className='mb-2'><Skeleton height='h-[20px]' width='w-[300px]' /></div>
|
||||
<div className='mb-2'><Skeleton height='h-[20px]' width='w-[300px]' /></div>
|
||||
</>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Flights;
|
||||
8
client/src/components/flights/FlightsState.interface.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { Alert } from "../../interfaces/Alert.interface";
|
||||
import { LogbookEntry } from "../logbook/LogbookEntry.interface";
|
||||
|
||||
export interface FlightsState {
|
||||
alert: Alert | undefined;
|
||||
flights: LogbookEntry[];
|
||||
isLoading: boolean;
|
||||
}
|
||||
44
client/src/components/flights/reducer.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { Alert } from "../../interfaces/Alert.interface";
|
||||
import { LogbookEntry } from "../logbook/LogbookEntry.interface";
|
||||
import { FlightsState } from "./FlightsState.interface";
|
||||
|
||||
|
||||
type Action =
|
||||
| { type: 'SET_ALERT'; payload: Alert | undefined }
|
||||
| { type: 'SET_FLIGHTS'; payload: LogbookEntry[] }
|
||||
| { type: 'SET_IS_LOADING'; payload: boolean }
|
||||
|
||||
export const initialState: FlightsState = {
|
||||
alert: undefined,
|
||||
flights: [],
|
||||
isLoading: true
|
||||
}
|
||||
|
||||
export const reducer = (
|
||||
state: FlightsState,
|
||||
action: Action
|
||||
): FlightsState => {
|
||||
switch (action.type) {
|
||||
case 'SET_ALERT': {
|
||||
return {
|
||||
...state,
|
||||
alert: action.payload
|
||||
}
|
||||
}
|
||||
case 'SET_FLIGHTS': {
|
||||
return {
|
||||
...state,
|
||||
flights: action.payload
|
||||
}
|
||||
}
|
||||
case 'SET_IS_LOADING': {
|
||||
return {
|
||||
...state,
|
||||
isLoading: action.payload
|
||||
}
|
||||
}
|
||||
default: {
|
||||
return state
|
||||
}
|
||||
}
|
||||
}
|
||||
740
client/src/components/logForm/LogForm.tsx
Normal file
@@ -0,0 +1,740 @@
|
||||
import React, { useEffect, useReducer } from 'react';
|
||||
import { Alert, Button, DatePicker, Drawer, DrawerBody, DrawerContent, DrawerFooter, DrawerHeader, Input, NumberInput, Select, Selection, SelectItem, SharedSelection, Textarea } from '@heroui/react'
|
||||
import { useForm, Controller, FormProvider, useFormContext } from 'react-hook-form';
|
||||
import { LogFormProps } from './LogFormProps.interface';
|
||||
import { initialState, reducer } from './reducer';
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
import { FormMode } from '../../enums/formMode';
|
||||
import { usePilots } from '../../hooks/pilots/UsePilots';
|
||||
import { useOidc } from '../../auth/oidcConfig';
|
||||
import httpClient from '../../httpClient/httpClient';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSave, faXmark } from '@fortawesome/free-solid-svg-icons'
|
||||
import { parseAbsolute, parseDate, getLocalTimeZone, CalendarDate, ZonedDateTime } from '@internationalized/date';
|
||||
import { useLogbookContext } from '../../hooks/logbookContext/UseLogbookContext';
|
||||
|
||||
const LogForm = () => {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const { control, formState, reset, setValue } = useFormContext()
|
||||
const { pilots } = usePilots();
|
||||
const { isUserLoggedIn } = useOidc();
|
||||
const logbookContext = useLogbookContext()
|
||||
|
||||
useEffect(() => {
|
||||
if (logbookContext.state.formMode === FormMode.VIEW) {
|
||||
dispatch({ type: 'SET_IS_DISABLED', payload: true });
|
||||
}
|
||||
}, [logbookContext.state.formMode]);
|
||||
|
||||
useEffect(() => {
|
||||
const getEntry = async () => {
|
||||
try {
|
||||
dispatch({ type: 'SET_IS_LOADING', payload: true });
|
||||
|
||||
const response: AxiosResponse = await httpClient.get(
|
||||
`api/logs/${logbookContext.state.selectedLogId}`
|
||||
);
|
||||
const log = response.data;
|
||||
|
||||
reset(log);
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError;
|
||||
|
||||
dispatch({ type: 'SET_ALERT', payload: { severity: 'danger', message: axiosError.message }});
|
||||
} finally {
|
||||
dispatch({ type: 'SET_IS_LOADING', payload: false });
|
||||
}
|
||||
};
|
||||
|
||||
if (logbookContext.state.selectedLogId) {
|
||||
getEntry();
|
||||
}
|
||||
}, [logbookContext.state.selectedLogId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (pilots && FormMode.ADD) {
|
||||
const newPilotsOptions = pilots.map((pilot) => {
|
||||
return {
|
||||
key: pilot.id,
|
||||
label: pilot.name,
|
||||
};
|
||||
});
|
||||
console.log(newPilotsOptions)
|
||||
dispatch({ type: 'SET_PILOT_OPTIONS', payload: newPilotsOptions });
|
||||
}
|
||||
}, [pilots]);
|
||||
|
||||
return (
|
||||
<div className='grid grid-cols-12 gap-3'>
|
||||
<div className={`col-span-4 self-center text-small after:content-['*'] after:text-danger after:ms-0.5`}>
|
||||
<span id='pilot'>Pilot</span>
|
||||
</div>
|
||||
<div className='col-span-8 self-center'>
|
||||
<Controller
|
||||
name="pilotId"
|
||||
control={control}
|
||||
render={({ field: { value } }) => {
|
||||
return (
|
||||
<Select
|
||||
aria-labelledby='pilot'
|
||||
isDisabled={state.isDisabled}
|
||||
fullWidth={true}
|
||||
isRequired={true}
|
||||
onSelectionChange={(keys: SharedSelection) => {
|
||||
setValue('pilotId', keys.currentKey);
|
||||
}}
|
||||
selectedKeys={[value]}
|
||||
size='lg'
|
||||
>
|
||||
{state.pilotOptions?.map((pilotOption: { key: string; label: string, }) => {
|
||||
return (
|
||||
<SelectItem key={pilotOption.key}>{pilotOption.label}</SelectItem>
|
||||
)
|
||||
})}
|
||||
</Select>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className={`col-span-4 self-center text-small after:content-['*'] after:text-danger after:ms-0.5`}>
|
||||
<span id='date'>Date</span>
|
||||
</div>
|
||||
<div className='col-span-8'>
|
||||
<Controller
|
||||
name="date"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
const parsedAbsoluteDate = value ? parseAbsolute(value, getLocalTimeZone()) : value
|
||||
|
||||
return(
|
||||
<DatePicker
|
||||
aria-labelledby='date'
|
||||
isDisabled={state.isDisabled}
|
||||
isRequired={true}
|
||||
onChange={(selectedDate) => {
|
||||
let date = selectedDate as CalendarDate;
|
||||
|
||||
setValue('date', date.toDate(getLocalTimeZone()).toISOString())
|
||||
}}
|
||||
size='lg'
|
||||
value={parsedAbsoluteDate ? new CalendarDate(parsedAbsoluteDate.year, parsedAbsoluteDate.month, parsedAbsoluteDate.day) : value}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className= {`col-span-4 self-center text-small after:content-['*'] after:text-danger after:ms-0.5`}>
|
||||
<span id='aircraftMakeModle'>Aircraft Make and Model</span>
|
||||
</div>
|
||||
<div className='col-span-8'>
|
||||
<Controller
|
||||
name="aircraftMakeModel"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Input
|
||||
aria-labelledby='aircraftMakeModel'
|
||||
isDisabled={state.isDisabled}
|
||||
color={formState.errors.address ? 'danger' : undefined}
|
||||
errorMessage={
|
||||
formState.errors.address
|
||||
? formState.errors.address.message?.toString()
|
||||
: undefined
|
||||
}
|
||||
isRequired={true}
|
||||
onChange={onChange}
|
||||
size='lg'
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{isUserLoggedIn &&
|
||||
<>
|
||||
<div className={`col-span-4 self-center text-small after:content-['*'] after:text-danger after:ms-0.5`}>
|
||||
<span id='aircraftIdentity'>Aircraft Identity</span>
|
||||
</div>
|
||||
<div className='col-span-8'>
|
||||
<Controller
|
||||
name="aircraftIdentity"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Input
|
||||
aria-labelledby='aircraftIdentity'
|
||||
isDisabled={state.isDisabled}
|
||||
color={formState.errors.address ? 'danger' : undefined}
|
||||
errorMessage={
|
||||
formState.errors.address
|
||||
? formState.errors.address.message?.toString()
|
||||
: undefined
|
||||
}
|
||||
isRequired={true}
|
||||
onChange={onChange}
|
||||
size='lg'
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
<div className={`col-span-4 self-center text-small after:content-['*'] after:text-danger after:ms-0.5`}>
|
||||
<span id='routeFrom'>Route From</span>
|
||||
</div>
|
||||
<div className='col-span-8'>
|
||||
<Controller
|
||||
name="routeFrom"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Input
|
||||
aria-labelledby='routeFrom'
|
||||
isDisabled={state.isDisabled}
|
||||
color={formState.errors.address ? 'danger' : undefined}
|
||||
errorMessage={
|
||||
formState.errors.address
|
||||
? formState.errors.address.message?.toString()
|
||||
: undefined
|
||||
}
|
||||
isRequired={true}
|
||||
onChange={onChange}
|
||||
size='lg'
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className={`col-span-4 self-center text-small after:content-['*'] after:text-danger after:ms-0.5`}>
|
||||
<span id='routeTo'>Route To</span>
|
||||
</div>
|
||||
<div className='col-span-8'>
|
||||
<Controller
|
||||
name="routeTo"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Input
|
||||
aria-labelledby='routeTo'
|
||||
isDisabled={state.isDisabled}
|
||||
color={formState.errors.address ? 'danger' : undefined}
|
||||
errorMessage={
|
||||
formState.errors.address
|
||||
? formState.errors.address.message?.toString()
|
||||
: undefined
|
||||
}
|
||||
isRequired={true}
|
||||
onChange={onChange}
|
||||
size='lg'
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className={`col-span-4 self-center text-small after:content-['*'] after:text-danger after:ms-0.5`}>
|
||||
<span id='durationOfFlight'>Duration Of Flight</span>
|
||||
</div>
|
||||
<div className='col-span-8'>
|
||||
<Controller
|
||||
name="durationOfFlight"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<NumberInput
|
||||
aria-labelledby='durationOfFlight'
|
||||
isDisabled={state.isDisabled}
|
||||
color={formState.errors.address ? 'danger' : undefined}
|
||||
errorMessage={
|
||||
formState.errors.address
|
||||
? formState.errors.address.message?.toString()
|
||||
: undefined
|
||||
}
|
||||
isRequired={true}
|
||||
isWheelDisabled={state.isDisabled}
|
||||
onChange={onChange}
|
||||
radius='lg'
|
||||
size='sm'
|
||||
type="number"
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
{isUserLoggedIn &&
|
||||
<>
|
||||
<div className={`col-span-4 self-center text-small after:content-['*'] after:text-danger after:ms-0.5`}>
|
||||
<span id='singleEngineLand'>Single Engine Land</span>
|
||||
</div>
|
||||
<div className='col-span-8'>
|
||||
<Controller
|
||||
name="singleEngineLand"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<NumberInput
|
||||
aria-labelledby='singleEngineLand'
|
||||
isDisabled={state.isDisabled}
|
||||
color={formState.errors.address ? 'danger' : undefined}
|
||||
errorMessage={
|
||||
formState.errors.address
|
||||
? formState.errors.address.message?.toString()
|
||||
: undefined
|
||||
}
|
||||
isRequired={true}
|
||||
isWheelDisabled={state.isDisabled}
|
||||
onChange={onChange}
|
||||
radius='lg'
|
||||
size='sm'
|
||||
type="number"
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='col-span-4 self-center text-small'>
|
||||
<span id='simulatorAtd'>Simulator or ATD</span>
|
||||
</div>
|
||||
<div className='col-span-8'>
|
||||
<Controller
|
||||
name="simulatorAtd"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<NumberInput
|
||||
aria-labelledby='simulaterAtd'
|
||||
isDisabled={state.isDisabled}
|
||||
color={formState.errors.address ? 'danger' : undefined}
|
||||
errorMessage={
|
||||
formState.errors.address
|
||||
? formState.errors.address.message?.toString()
|
||||
: undefined
|
||||
}
|
||||
isWheelDisabled={state.isDisabled}
|
||||
onChange={onChange}
|
||||
radius='lg'
|
||||
size='sm'
|
||||
type="number"
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='col-span-12'>
|
||||
<h4>Landings</h4>
|
||||
</div>
|
||||
<div className='col-span-4 self-center text-small'>
|
||||
<span id='landingsDay'>Day</span>
|
||||
</div>
|
||||
<div className='col-span-8'>
|
||||
<Controller
|
||||
name="landingsDay"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<NumberInput
|
||||
aria-labelledby='landingsDay'
|
||||
isDisabled={state.isDisabled}
|
||||
color={
|
||||
formState.errors.address ? 'danger' : undefined
|
||||
}
|
||||
errorMessage={
|
||||
formState.errors.address
|
||||
? formState.errors.address.message?.toString()
|
||||
: undefined
|
||||
}
|
||||
isWheelDisabled={state.isDisabled}
|
||||
onChange={onChange}
|
||||
radius='lg'
|
||||
size='sm'
|
||||
type="number"
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='col-span-4 self-center text-small'>
|
||||
<span id='landingsNight'>Night</span>
|
||||
</div>
|
||||
<div className='col-span-8'>
|
||||
<Controller
|
||||
name="landingsNight"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<NumberInput
|
||||
aria-labelledby='landingsNight'
|
||||
isDisabled={state.isDisabled}
|
||||
color={
|
||||
formState.errors.address ? 'danger' : undefined
|
||||
}
|
||||
errorMessage={
|
||||
formState.errors.address
|
||||
? formState.errors.address.message?.toString()
|
||||
: undefined
|
||||
}
|
||||
isWheelDisabled={state.isDisabled}
|
||||
onChange={onChange}
|
||||
radius='lg'
|
||||
size='sm'
|
||||
type="number"
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='col-span-12'>
|
||||
<h4>Type of Training or Experience</h4>
|
||||
</div>
|
||||
<div className='col-span-4 self-center'>
|
||||
<span id='groundTrainingsReceived'>
|
||||
Ground Training Received
|
||||
</span>
|
||||
</div>
|
||||
<div className='col-span-8'>
|
||||
<Controller
|
||||
name="groundTrainingReceived"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<NumberInput
|
||||
aria-labelledby='groundTrainingReceived'
|
||||
isDisabled={state.isDisabled}
|
||||
color={
|
||||
formState.errors.address ? 'danger' : undefined
|
||||
}
|
||||
errorMessage={
|
||||
formState.errors.address
|
||||
? formState.errors.address.message?.toString()
|
||||
: undefined
|
||||
}
|
||||
isWheelDisabled={state.isDisabled}
|
||||
onChange={onChange}
|
||||
radius='lg'
|
||||
size='sm'
|
||||
type="number"
|
||||
value={value}
|
||||
width='w-full'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='col-span-4 self-center'>
|
||||
<span id='flightTrainingReceived'>
|
||||
Flight Training Received
|
||||
</span>
|
||||
</div>
|
||||
<div className='col-span-8'>
|
||||
<Controller
|
||||
name="flightTrainingReceived"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<NumberInput
|
||||
aria-labelledby='flightTrainingReceived'
|
||||
isDisabled={state.isDisabled}
|
||||
color={
|
||||
formState.errors.address ? 'danger' : undefined
|
||||
}
|
||||
errorMessage={
|
||||
formState.errors.address
|
||||
? formState.errors.address.message?.toString()
|
||||
: undefined
|
||||
}
|
||||
isWheelDisabled={state.isDisabled}
|
||||
onChange={onChange}
|
||||
radius='lg'
|
||||
size='sm'
|
||||
type="number"
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='col-span-4 self-center'>
|
||||
<span id='crossCountry'>Cross Country</span>
|
||||
</div>
|
||||
<div className='col-span-8'>
|
||||
<Controller
|
||||
name="crossCountry"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<NumberInput
|
||||
aria-labelledby='crossCountry'
|
||||
isDisabled={state.isDisabled}
|
||||
color={
|
||||
formState.errors.address ? 'danger' : undefined
|
||||
}
|
||||
errorMessage={
|
||||
formState.errors.address
|
||||
? formState.errors.address.message?.toString()
|
||||
: undefined
|
||||
}
|
||||
isWheelDisabled={state.isDisabled}
|
||||
onChange={onChange}
|
||||
radius='lg'
|
||||
size='sm'
|
||||
type="number"
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='col-span-4 self-center'>
|
||||
<span id='night'>Night</span>
|
||||
</div>
|
||||
<div className='col-span-8'>
|
||||
<Controller
|
||||
name="night"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<NumberInput
|
||||
aria-labelledby='night'
|
||||
isDisabled={state.isDisabled}
|
||||
color={
|
||||
formState.errors.address ? 'danger' : undefined
|
||||
}
|
||||
errorMessage={
|
||||
formState.errors.address
|
||||
? formState.errors.address.message?.toString()
|
||||
: undefined
|
||||
}
|
||||
isWheelDisabled={state.isDisabled}
|
||||
onChange={onChange}
|
||||
radius='lg'
|
||||
size='sm'
|
||||
type="number"
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='col-span-4 self-center'>
|
||||
<span id='solo'>Solo</span>
|
||||
</div>
|
||||
<div className='col-span-8'>
|
||||
<Controller
|
||||
name="solo"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<NumberInput
|
||||
aria-labelledby='solo'
|
||||
isDisabled={state.isDisabled}
|
||||
color={
|
||||
formState.errors.address ? 'danger' : undefined
|
||||
}
|
||||
errorMessage={
|
||||
formState.errors.address
|
||||
? formState.errors.address.message?.toString()
|
||||
: undefined
|
||||
}
|
||||
isWheelDisabled={state.isDisabled}
|
||||
onChange={onChange}
|
||||
radius='lg'
|
||||
size='sm'
|
||||
type="number"
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='col-span-4 self-center'>
|
||||
<span id='pilotInCommand'>Pilot in Command</span>
|
||||
</div>
|
||||
<div className='col-span-8'>
|
||||
<Controller
|
||||
name="pilotInCommand"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<NumberInput
|
||||
aria-labelledby='pilotInCommand'
|
||||
isDisabled={state.isDisabled}
|
||||
color={
|
||||
formState.errors.address ? 'danger' : undefined
|
||||
}
|
||||
errorMessage={
|
||||
formState.errors.address
|
||||
? formState.errors.address.message?.toString()
|
||||
: undefined
|
||||
}
|
||||
isWheelDisabled={state.isDisabled}
|
||||
onChange={onChange}
|
||||
radius='lg'
|
||||
size='sm'
|
||||
type="number"
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='col-span-12 self-center'>
|
||||
<h4>Instrument</h4>
|
||||
</div>
|
||||
<div className='col-span-4 self-center'>
|
||||
<span id='instrumentActual'>Actual</span>
|
||||
</div>
|
||||
<div className='col-span-8'>
|
||||
<Controller
|
||||
name="instrumentActual"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<NumberInput
|
||||
aria-labelledby='instrumentActual'
|
||||
isDisabled={state.isDisabled}
|
||||
color={
|
||||
formState.errors.address ? 'danger' : undefined
|
||||
}
|
||||
errorMessage={
|
||||
formState.errors.address
|
||||
? formState.errors.address.message?.toString()
|
||||
: undefined
|
||||
}
|
||||
isWheelDisabled={state.isDisabled}
|
||||
onChange={onChange}
|
||||
radius='lg'
|
||||
size='sm'
|
||||
type="number"
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='col-span-4 self-center'>
|
||||
<span id='instrumentSimulated'>Simulated</span>
|
||||
</div>
|
||||
<div className='col-span-8'>
|
||||
<Controller
|
||||
name="instrumentSimulated"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<NumberInput
|
||||
aria-labelledby='instrumentSimulated'
|
||||
isDisabled={state.isDisabled}
|
||||
color={
|
||||
formState.errors.address ? 'danger' : undefined
|
||||
}
|
||||
errorMessage={
|
||||
formState.errors.address
|
||||
? formState.errors.address.message?.toString()
|
||||
: undefined
|
||||
}
|
||||
isWheelDisabled={state.isDisabled}
|
||||
onChange={onChange}
|
||||
radius='lg'
|
||||
size='sm'
|
||||
type="number"
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='col-span-4 self-center'>
|
||||
<span id='instrumentApproaches'>
|
||||
Instrument Approaches
|
||||
</span>
|
||||
</div>
|
||||
<div className='col-span-8'>
|
||||
<Controller
|
||||
name="instrumentApproaches"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<NumberInput
|
||||
aria-labelledby='instrumentApproaches'
|
||||
isDisabled={state.isDisabled}
|
||||
color={
|
||||
formState.errors.address ? 'danger' : undefined
|
||||
}
|
||||
errorMessage={
|
||||
formState.errors.address
|
||||
? formState.errors.address.message?.toString()
|
||||
: undefined
|
||||
}
|
||||
isWheelDisabled={state.isDisabled}
|
||||
onChange={onChange}
|
||||
radius='lg'
|
||||
size='sm'
|
||||
type="number"
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='col-span-4 self-center'>
|
||||
<span id='instrumentHolds'>Holds</span>
|
||||
</div>
|
||||
<div className='col-span-8'>
|
||||
<Controller
|
||||
name="instrumentHolds"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<NumberInput
|
||||
aria-labelledby='instrumentHolds'
|
||||
isDisabled={state.isDisabled}
|
||||
color={
|
||||
formState.errors.address ? 'danger' : undefined
|
||||
}
|
||||
errorMessage={
|
||||
formState.errors.address
|
||||
? formState.errors.address.message?.toString()
|
||||
: undefined
|
||||
}
|
||||
isWheelDisabled={state.isDisabled}
|
||||
onChange={onChange}
|
||||
radius='lg'
|
||||
size='sm'
|
||||
type="number"
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='col-span-4 self-center'>
|
||||
<span id='instrumentNavTrack'>Nav / Track</span>
|
||||
</div>
|
||||
<div className='col-span-8'>
|
||||
<Controller
|
||||
name="instrumentNavTrack"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<NumberInput
|
||||
aria-labelledby='instrumentNavTrack'
|
||||
isDisabled={state.isDisabled}
|
||||
color={
|
||||
formState.errors.address ? 'danger' : undefined
|
||||
}
|
||||
errorMessage={
|
||||
formState.errors.address
|
||||
? formState.errors.address.message?.toString()
|
||||
: undefined
|
||||
}
|
||||
fullWidth={true}
|
||||
isWheelDisabled={state.isDisabled}
|
||||
onChange={onChange}
|
||||
radius='lg'
|
||||
size='sm'
|
||||
type="number"
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
<div className='col-span-12'>
|
||||
<h4 id='notes'>Notes</h4>
|
||||
</div>
|
||||
<div className='col-span-12'>
|
||||
<Controller
|
||||
name="notes"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Textarea
|
||||
aria-labelledby='notes'
|
||||
isDisabled={state.isDisabled}
|
||||
color={formState.errors.address ? 'danger' : undefined}
|
||||
errorMessage={
|
||||
formState.errors.address
|
||||
? formState.errors.address.message?.toString()
|
||||
: undefined
|
||||
}
|
||||
fullWidth={true}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
);
|
||||
};
|
||||
|
||||
export default LogForm;
|
||||
8
client/src/components/logForm/LogFormProps.interface.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { FormMode } from '../../enums/formMode';
|
||||
|
||||
export interface LogFormProps {
|
||||
logId?: string;
|
||||
// isDrawerOpen: boolean;
|
||||
mode: FormMode;
|
||||
// onOpenClose: (mode: FormMode) => void;
|
||||
}
|
||||
13
client/src/components/logForm/LogFormState.interface.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Selection } from "@heroui/react";
|
||||
import { Alert } from "../../interfaces/Alert.interface";
|
||||
|
||||
export interface LogFormState {
|
||||
alert: Alert | undefined;
|
||||
experienceSelectedKeys: Selection;
|
||||
instrumentSelectedKeys: Selection;
|
||||
isDisabled: boolean;
|
||||
isLoading: boolean;
|
||||
landingsSelectedKeys: Selection;
|
||||
pilotOptions: { key: string; label: string; }[];
|
||||
selectedPilotName: string;
|
||||
}
|
||||
83
client/src/components/logForm/reducer.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Alert } from '../../interfaces/Alert.interface';
|
||||
import { LogFormState } from './LogFormState.interface';
|
||||
import { Selection } from '@heroui/react';
|
||||
|
||||
type Action =
|
||||
| { type: 'SET_ALERT'; payload: Alert | undefined }
|
||||
| { type: 'SET_EXPERIENCE_SELECTED_KEYS'; payload: Selection }
|
||||
| { type: 'SET_INSTRUMENT_SELECTED_KEYS'; payload: Selection }
|
||||
| { type: 'SET_IS_DISABLED'; payload: boolean }
|
||||
| { type: 'SET_IS_LOADING'; payload: boolean }
|
||||
| { type: 'SET_LANDINGS_SELECTED_KEYS'; payload: Selection }
|
||||
| { type: 'SET_PILOT_OPTIONS'; payload: { key: string, label: string; }[] }
|
||||
| { type: 'SET_SELECTED_PILOT_NAME'; payload: string };
|
||||
|
||||
export const initialState: LogFormState = {
|
||||
alert: undefined,
|
||||
experienceSelectedKeys: new Set([]),
|
||||
instrumentSelectedKeys: new Set([]),
|
||||
isDisabled: false,
|
||||
isLoading: true,
|
||||
landingsSelectedKeys: new Set(['1']),
|
||||
pilotOptions: [],
|
||||
selectedPilotName: ''
|
||||
};
|
||||
|
||||
export const reducer = (
|
||||
state: LogFormState,
|
||||
action: Action
|
||||
): LogFormState => {
|
||||
switch (action.type) {
|
||||
case 'SET_ALERT': {
|
||||
return {
|
||||
...state,
|
||||
alert: action.payload
|
||||
};
|
||||
}
|
||||
case 'SET_EXPERIENCE_SELECTED_KEYS': {
|
||||
return {
|
||||
...state,
|
||||
experienceSelectedKeys: action.payload
|
||||
}
|
||||
}
|
||||
case 'SET_IS_DISABLED': {
|
||||
return {
|
||||
...state,
|
||||
isDisabled: action.payload
|
||||
};
|
||||
}
|
||||
case 'SET_INSTRUMENT_SELECTED_KEYS': {
|
||||
return {
|
||||
...state,
|
||||
instrumentSelectedKeys: action.payload
|
||||
}
|
||||
}
|
||||
case 'SET_IS_LOADING': {
|
||||
return {
|
||||
...state,
|
||||
isLoading: action.payload
|
||||
};
|
||||
}
|
||||
case 'SET_LANDINGS_SELECTED_KEYS': {
|
||||
return {
|
||||
...state,
|
||||
landingsSelectedKeys: action.payload
|
||||
}
|
||||
}
|
||||
case 'SET_PILOT_OPTIONS': {
|
||||
return {
|
||||
...state,
|
||||
pilotOptions: action.payload
|
||||
};
|
||||
}
|
||||
case 'SET_SELECTED_PILOT_NAME': {
|
||||
return {
|
||||
...state,
|
||||
selectedPilotName: action.payload
|
||||
};
|
||||
}
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
657
client/src/components/logbook/Logbook.tsx
Normal file
@@ -0,0 +1,657 @@
|
||||
import { Key, useEffect, useReducer } from 'react';
|
||||
import LogForm from '../logForm/LogForm';
|
||||
import { initialState, reducer } from './reducer';
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
import { FormMode } from '../../enums/formMode';
|
||||
import { authColumns, unauthColumns } from './columns';
|
||||
import ActionMenu from '../actionMenu/ActionMenu';
|
||||
import ConfirmationDialog from '../confirmationDialog/ConfirmationDialog';
|
||||
import { LogbookEntry } from './LogbookEntry.interface';
|
||||
import LogbookCard from '../logbookCard/LogbookCard';
|
||||
import { useOidc } from '../../auth/oidcConfig';
|
||||
import httpClient from '../../httpClient/httpClient';
|
||||
import { useUserRole } from '../../hooks/userRole/UseUserRole';
|
||||
import { UserRole } from '../../enums/userRole';
|
||||
import { useBreakpoints } from '../../hooks/useBreakpoints/UseBreakpoints';
|
||||
import { ScreenSize } from '../../enums/screenSize';
|
||||
import { Table, TableHeader, TableBody, TableColumn, Dropdown, DropdownTrigger, Button, DropdownSection, DropdownMenu, DropdownItem, Alert, TableRow, TableCell } from '@heroui/react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faAdd, faEllipsisVertical, faPen, faEye, faTrash, faMapLocationDot } from '@fortawesome/free-solid-svg-icons'
|
||||
import { CellContext, ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, HeaderContext, useReactTable } from '@tanstack/react-table';
|
||||
import { useLogbookContext } from '../../hooks/logbookContext/UseLogbookContext';
|
||||
import LogbookDrawer from '../logbookDrawer/LogbookDrawer';
|
||||
|
||||
const Logbook: React.FC<unknown> = () => {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const logbookContext = useLogbookContext()
|
||||
const { isUserLoggedIn } = useOidc();
|
||||
const { userRole } = useUserRole();
|
||||
const { screenSize } = useBreakpoints();
|
||||
const columnTotal = (info: HeaderContext<LogbookEntry, unknown>): number => {
|
||||
const blah = info.table
|
||||
const values: number[] = info.table.getPaginationRowModel().rows.map((row: any) => Number(row.getValue(info.column.id))).filter((value: any) => !Number.isNaN(value));
|
||||
let total: number = 0;
|
||||
|
||||
if (values.length > 0) {
|
||||
total = values.reduce((accumulator, currentValue) => accumulator + currentValue, total)
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
const pilotName: ColumnDef<LogbookEntry> = {
|
||||
id: 'pilotName',
|
||||
accessorKey: 'pilot',
|
||||
header: 'Pilot',
|
||||
footer: 'PAGE TOTALS',
|
||||
cell: (info: CellContext<LogbookEntry, unknown>) => {
|
||||
const pilot: any = info.getValue();
|
||||
|
||||
return pilot.name
|
||||
}
|
||||
}
|
||||
const date: ColumnDef<LogbookEntry> = {
|
||||
id: 'date',
|
||||
accessorKey: 'date',
|
||||
header: 'Date',
|
||||
cell: (info: CellContext<LogbookEntry, unknown>) => {
|
||||
const date = new Date(info.getValue() as string);
|
||||
const formattedDate = `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`
|
||||
|
||||
return formattedDate;
|
||||
}
|
||||
}
|
||||
const aircraftMakeModel: ColumnDef<LogbookEntry> = {
|
||||
id: 'aircraftMakeModel',
|
||||
accessorKey: 'aircraftMakeModel',
|
||||
header: 'Aircraft Make & Model'
|
||||
}
|
||||
const route: ColumnDef<LogbookEntry> = {
|
||||
id: 'route',
|
||||
header: 'Route of Flight',
|
||||
meta: {
|
||||
headerAlign: 'center'
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
id: 'routeFrom',
|
||||
accessorKey: 'routeFrom',
|
||||
header: 'From'
|
||||
},
|
||||
{
|
||||
id: 'routeTo',
|
||||
accessorKey: 'routeTo',
|
||||
header: 'To'
|
||||
}
|
||||
]
|
||||
}
|
||||
const durationOfFlight: ColumnDef<LogbookEntry> = {
|
||||
id: 'durationOfFlight',
|
||||
accessorKey: 'durationOfFlight',
|
||||
header: 'Duration Of Flight',
|
||||
meta: {
|
||||
align: 'right',
|
||||
headerAlign: 'right'
|
||||
},
|
||||
cell: (info: CellContext<LogbookEntry, unknown>) =>
|
||||
info.getValue() ? parseFloat(info.getValue() as string).toFixed(1) : '',
|
||||
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : ''
|
||||
}
|
||||
const notes: ColumnDef<LogbookEntry> = {
|
||||
id: 'notes',
|
||||
accessorKey: 'notes',
|
||||
header: 'Notes'
|
||||
}
|
||||
const actions: ColumnDef<LogbookEntry> = {
|
||||
id: 'actions',
|
||||
header: 'Actions',
|
||||
meta: {
|
||||
align: 'center',
|
||||
headerAlign: 'center'
|
||||
},
|
||||
cell: (info: CellContext<LogbookEntry, unknown>) => {
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly variant='light' size='lg'>
|
||||
<FontAwesomeIcon icon={faEllipsisVertical} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu>
|
||||
<DropdownSection showDivider>
|
||||
<DropdownItem
|
||||
key='edit'
|
||||
onPress={() => onOpenCloseDrawer(FormMode.EDIT, info.row.original.id)}
|
||||
startContent={<FontAwesomeIcon icon={faPen} />}
|
||||
>
|
||||
Edit
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key='view'
|
||||
onPress={() => onOpenCloseDrawer(FormMode.VIEW, info.row.original.id)}
|
||||
startContent={<FontAwesomeIcon icon={faEye} />}
|
||||
>
|
||||
View
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
<DropdownSection>
|
||||
<DropdownItem
|
||||
key='Delete'
|
||||
onPress={() => onDeleteLog(info.row.original.id)}
|
||||
startContent={<FontAwesomeIcon icon={faTrash} />}
|
||||
>
|
||||
Delete
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
const unauthColumns: ColumnDef<LogbookEntry>[] = [
|
||||
pilotName,
|
||||
date,
|
||||
aircraftMakeModel,
|
||||
route,
|
||||
durationOfFlight,
|
||||
notes
|
||||
]
|
||||
|
||||
const authColumns: ColumnDef<LogbookEntry>[] = [
|
||||
pilotName,
|
||||
date,
|
||||
aircraftMakeModel,
|
||||
{
|
||||
id: 'aircraftIdentity',
|
||||
accessorKey: 'aircraftIdentity',
|
||||
header: 'Aircraft Identity',
|
||||
},
|
||||
route,
|
||||
durationOfFlight,
|
||||
{
|
||||
id: 'singleEngineLand',
|
||||
accessorKey: 'singleEngineLand',
|
||||
header: 'Single Engine Land',
|
||||
meta: {
|
||||
align: 'right',
|
||||
headerAlign: 'right'
|
||||
},
|
||||
cell: (info: CellContext<LogbookEntry, unknown>) =>
|
||||
info.getValue() ? parseFloat(info.getValue() as string).toFixed(1) : '',
|
||||
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : ''
|
||||
},
|
||||
{
|
||||
id: 'landings',
|
||||
header: 'Landings',
|
||||
meta: {
|
||||
headerAlign: 'center'
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
id: 'landingsDay',
|
||||
accessorKey: 'landingsDay',
|
||||
header: 'Day',
|
||||
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info) : '',
|
||||
meta: {
|
||||
align: 'right',
|
||||
headerAlign: 'right'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'landingsNight',
|
||||
accessorKey: 'landingsNight',
|
||||
header: 'Night',
|
||||
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info) : '',
|
||||
meta: {
|
||||
align: 'right',
|
||||
headerAlign: 'right'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'instrument',
|
||||
header: 'Instrument',
|
||||
meta: {
|
||||
headerAlign: 'center'
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
id: 'instrumentActual',
|
||||
accessorKey: 'instrumentActual',
|
||||
header: 'Actual',
|
||||
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
|
||||
meta: {
|
||||
align: 'right',
|
||||
headerAlign: 'right'
|
||||
},
|
||||
cell: (info: CellContext<LogbookEntry, unknown>) =>
|
||||
info.getValue() ? parseFloat(info.getValue() as string).toFixed(1) : '',
|
||||
},
|
||||
{
|
||||
id: 'instrumentSimulated',
|
||||
accessorKey: 'instrumentSimulated',
|
||||
header: 'Simulated',
|
||||
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
|
||||
meta: {
|
||||
align: 'right',
|
||||
headerAlign: 'right'
|
||||
},
|
||||
cell: (info: CellContext<LogbookEntry, unknown>) =>
|
||||
info.getValue() ? parseFloat(info.getValue() as string).toFixed(1) : ''
|
||||
},
|
||||
{
|
||||
id: 'instrumentApproaches',
|
||||
accessorKey: 'instrumentApproaches',
|
||||
header: 'Approaches',
|
||||
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
|
||||
meta: {
|
||||
align: 'right',
|
||||
headerAlign: 'right'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'instrumentHolds',
|
||||
accessorKey: 'instrumentHolds',
|
||||
header: 'Holds',
|
||||
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
|
||||
meta: {
|
||||
align: 'right',
|
||||
headerAlign: 'right'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'instrumentNavTrack',
|
||||
accessorKey: 'instrumentNavTrack',
|
||||
header: 'Nav/Track',
|
||||
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
|
||||
meta: {
|
||||
align: 'right',
|
||||
headerAlign: 'right'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'experienceTraining',
|
||||
header: 'Type of pilot experience or training',
|
||||
meta: {
|
||||
headerAlign: 'center'
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
id: 'groundTrainingReceived',
|
||||
accessorKey: 'groundTrainingReceived',
|
||||
header: 'Ground Training Received',
|
||||
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
|
||||
meta: {
|
||||
align: 'right',
|
||||
headerAlign: 'right'
|
||||
},
|
||||
cell: (info: CellContext<LogbookEntry, unknown>) =>
|
||||
info.getValue() ? parseFloat(info.getValue() as string).toFixed(1) : ''
|
||||
},
|
||||
{
|
||||
id: 'flightTrainingReceived',
|
||||
accessorKey: 'flightTrainingReceived',
|
||||
header: 'Flight Training Received',
|
||||
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
|
||||
meta: {
|
||||
align: 'right',
|
||||
headerAlign: 'right'
|
||||
},
|
||||
cell: (info: CellContext<LogbookEntry, unknown>) =>
|
||||
info.getValue() ? parseFloat(info.getValue() as string).toFixed(1) : ''
|
||||
},
|
||||
{
|
||||
id: 'crossCountry',
|
||||
accessorKey: 'crossCountry',
|
||||
header: 'Cross Country',
|
||||
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
|
||||
meta: {
|
||||
align: 'right',
|
||||
headerAlign: 'right'
|
||||
},
|
||||
cell: (info: CellContext<LogbookEntry, unknown>) =>
|
||||
info.getValue() ? parseFloat(info.getValue() as string).toFixed(1) : ''
|
||||
},
|
||||
{
|
||||
id: 'night',
|
||||
accessorKey: 'night',
|
||||
header: 'Night',
|
||||
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
|
||||
meta: {
|
||||
align: 'right',
|
||||
headerAlign: 'right'
|
||||
},
|
||||
cell: (info: CellContext<LogbookEntry, unknown>) =>
|
||||
info.getValue() ? parseFloat(info.getValue() as string).toFixed(1) : ''
|
||||
},
|
||||
{
|
||||
id: 'solo',
|
||||
accessorKey: 'solo',
|
||||
header: 'Solo',
|
||||
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
|
||||
meta: {
|
||||
align: 'right',
|
||||
headerAlign: 'right'
|
||||
},
|
||||
cell: (info: CellContext<LogbookEntry, unknown>) =>
|
||||
info.getValue() ? parseFloat(info.getValue() as string).toFixed(1) : ''
|
||||
},
|
||||
{
|
||||
id: 'pilotInCommand',
|
||||
accessorKey: 'pilotInCommand',
|
||||
header: 'Pilot In Command',
|
||||
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
|
||||
meta: {
|
||||
align: 'right',
|
||||
headerAlign: 'right'
|
||||
},
|
||||
cell: (info: CellContext<LogbookEntry, unknown>) =>
|
||||
info.getValue() ? parseFloat(info.getValue() as string).toFixed(1) : ''
|
||||
}
|
||||
]
|
||||
},
|
||||
notes,
|
||||
actions
|
||||
]
|
||||
|
||||
const getLogbookEntries = async () => {
|
||||
try {
|
||||
dispatch({ type: 'SET_IS_LOADING', payload: true });
|
||||
|
||||
const response: AxiosResponse = await httpClient.get(`api/logs`);
|
||||
const entries: LogbookEntry[] = response.data;
|
||||
entries.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||
|
||||
if (response.data.length > 0) {
|
||||
dispatch({ type: 'SET_ENTRIES', payload: response.data });
|
||||
|
||||
if (state.alert) {
|
||||
dispatch({ type: 'SET_ALERT', payload: undefined})
|
||||
}
|
||||
} else {
|
||||
dispatch({ type: 'SET_ALERT', payload: { severity: 'default', message: 'No logbook entries found.'}})
|
||||
}
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError;
|
||||
|
||||
dispatch({
|
||||
type: 'SET_ALERT',
|
||||
payload: { severity: 'danger', message: `Loading of logbook entries failed with the following message: ${axiosError.message}`}
|
||||
});
|
||||
} finally {
|
||||
dispatch({ type: 'SET_IS_LOADING', payload: false });
|
||||
}
|
||||
};
|
||||
|
||||
const onOpenCloseDrawer= (mode: FormMode, logId?: string) => {
|
||||
switch (mode) {
|
||||
case FormMode.ADD:
|
||||
case FormMode.EDIT:
|
||||
case FormMode.VIEW:
|
||||
logbookContext.dispatch({
|
||||
type: 'SET_OPEN_CLOSE_DRAWER',
|
||||
payload: {
|
||||
formMode: mode,
|
||||
selectedLogId: logId!,
|
||||
isDrawerOpen: true
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
case FormMode.CANCEL:
|
||||
logbookContext.dispatch({
|
||||
type: 'SET_OPEN_CLOSE_DRAWER',
|
||||
payload: {
|
||||
formMode: mode,
|
||||
selectedLogId: undefined,
|
||||
isDrawerOpen: false
|
||||
}
|
||||
});
|
||||
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const onDeleteLog = (logId: string) => {
|
||||
dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_OPEN', payload: true });
|
||||
logbookContext.dispatch({ type: 'SET_SELECTED_LOG_ID', payload: logId })
|
||||
};
|
||||
|
||||
const onConfirmationDialogConfirm = async () => {
|
||||
try {
|
||||
dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: true });
|
||||
|
||||
await httpClient.delete(`api/logs/${logbookContext.state.selectedLogId}`);
|
||||
|
||||
dispatch({
|
||||
type: 'SET_IS_CONFIRMATION_DIALOG_OPEN',
|
||||
payload: false
|
||||
});
|
||||
logbookContext.dispatch({ type: 'SET_SELECTED_LOG_ID', payload: '' })
|
||||
await getLogbookEntries();
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError;
|
||||
|
||||
dispatch({
|
||||
type: 'SET_ALERT',
|
||||
payload: { severity: 'danger', message: `Loading of logbook entries failed with the following message: ${axiosError.message}`}
|
||||
});
|
||||
} finally {
|
||||
dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: false });
|
||||
}
|
||||
};
|
||||
|
||||
const onConfirmationDialogCancel = () => {
|
||||
dispatch({
|
||||
type: 'SET_IS_CONFIRMATION_DIALOG_OPEN',
|
||||
payload: false
|
||||
});
|
||||
};
|
||||
|
||||
// useEffect(() => {
|
||||
// let newColumns: ColumnDef<LogbookEntry>[];
|
||||
|
||||
// if (userRole === UserRole.WRITE) {
|
||||
// newColumns = [...authColumns];
|
||||
// } else {
|
||||
// newColumns = [...unauthColumns];
|
||||
// }
|
||||
|
||||
// const actionsColumnExists = newColumns.find((column) => column.id === 'actions');
|
||||
// const tracksColumnExists = newColumns.find((column) => column.id === 'actions');
|
||||
|
||||
// if (!actionsColumnExists) {
|
||||
// newColumns.push(actionsColumn);
|
||||
// }
|
||||
|
||||
// if (!tracksColumnExists) {
|
||||
// const notesColumnIndex = newColumns.findIndex((column) => column.id === 'notes')
|
||||
|
||||
// newColumns.splice(notesColumnIndex, 0, tracksColumn)
|
||||
// }
|
||||
|
||||
// dispatch({ type: 'SET_COLUMNS', payload: newColumns })
|
||||
// }, [isUserLoggedIn])
|
||||
|
||||
const table = useReactTable({
|
||||
data: state.entries,
|
||||
columns: authColumns,
|
||||
getCoreRowModel: getCoreRowModel(),
|
||||
getPaginationRowModel: getPaginationRowModel()
|
||||
});
|
||||
|
||||
const textAlignment = {
|
||||
center: 'text-center',
|
||||
left: 'text-start',
|
||||
right: 'text-end'
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!logbookContext.state.isDrawerOpen) {
|
||||
getLogbookEntries();
|
||||
}
|
||||
}, [logbookContext.state.isDrawerOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='mr-10 ml-10 grid grid-cols-12'>
|
||||
<div className='prose max-w-none col-span-10 mt-5 mb-5'>
|
||||
<h1>Logbook</h1>
|
||||
</div>
|
||||
<div className='col-span-2 justify-self-end self-center'>
|
||||
{userRole === UserRole.WRITE &&
|
||||
<Button
|
||||
color='primary'
|
||||
onPress={() => onOpenCloseDrawer(FormMode.ADD)}
|
||||
startContent={<FontAwesomeIcon icon={faAdd} />}
|
||||
data-testid="pilot-add-button"
|
||||
data-theme="lofi"
|
||||
>
|
||||
Add Entry
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
{!state.isLoading && state.alert && (
|
||||
<div className='col-span-12 mb-5'>
|
||||
<Alert
|
||||
onClose={() =>
|
||||
dispatch({ type: 'SET_ALERT', payload: undefined })
|
||||
}
|
||||
color={'default'}
|
||||
title={state.alert.message}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!state.isLoading && (
|
||||
<div className='col-span-12'>
|
||||
{state.entries.length > 0 && screenSize !== ScreenSize.SM && (
|
||||
<div className='p-4 z-0 flex flex-col relative justify-between gap-4 bg-content1 overflow-auto shadow-small rounded-large w-full'>
|
||||
<table className='min-w-full h-auto table-auto w-full'>
|
||||
<thead className='[&>tr]:first:rounded-lg'>
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<tr className='group/tr outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2' key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => {
|
||||
return (
|
||||
<th
|
||||
className={`${header.column.columnDef.meta?.align ? textAlignment[header.column.columnDef.meta?.align] : ''} group/th px-3 h-10 align-middle bg-default-100 whitespace-nowrap text-foreground-500 text-tiny font-semibold first:rounded-s-lg last:rounded-e-lg data-[sortable=true]:cursor-pointer data-[hover=true]:text-foreground-400 outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2 text-start`}
|
||||
colSpan={header.colSpan}
|
||||
key={header.id}
|
||||
>
|
||||
{header.isPlaceholder ? null : (
|
||||
<div>
|
||||
{flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext()
|
||||
)}
|
||||
{/* {header.column.getCanFilter() ? (
|
||||
<div>
|
||||
<Filter column={header.column} table={table} />
|
||||
</div>
|
||||
) : null} */}
|
||||
</div>
|
||||
)}
|
||||
</th>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
))}
|
||||
</thead>
|
||||
<tbody>
|
||||
<>
|
||||
{table.getRowModel().rows.map((row) => {
|
||||
return (
|
||||
<tr className='group/tr outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2' key={row.id}>
|
||||
{row.getVisibleCells().map((cell) => {
|
||||
return (
|
||||
<td
|
||||
className={`${cell.column.columnDef.meta?.align ? textAlignment[cell.column.columnDef.meta?.align] : ''} py-2 px-3 relative align-middle whitespace-normal text-small font-normal [&>*]:z-1 [&>*]:relative outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2 before:pointer-events-none before:content-[''] before:absolute before:z-0 before:inset-0 before:opacity-0 data-[selected=true]:before:opacity-100 group-data-[disabled=true]/tr:text-foreground-300 group-data-[disabled=true]/tr:cursor-not-allowed before:bg-default/60 data-[selected=true]:text-default-foreground first:before:rounded-s-lg last:before:rounded-e-lg text-start`}
|
||||
key={cell.id}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext()
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
</tbody>
|
||||
<thead className='[&>tr]:first:rounded-lg'>
|
||||
{table.getFooterGroups().map((footerGroup, index) => {
|
||||
if (index === 0) {
|
||||
return (
|
||||
<tr className='group/tr outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2' key={footerGroup.id}>
|
||||
{footerGroup.headers.map((header) => {
|
||||
return (
|
||||
<td
|
||||
className='group/th px-3 h-10 align-middle bg-default-100 whitespace-nowrap text-foreground-500 text-tiny font-semibold first:rounded-s-lg last:rounded-e-lg data-[sortable=true]:cursor-pointer data-[hover=true]:text-foreground-400 outline-solid outline-transparent data-[focus-visible=true]:z-10 data-[focus-visible=true]:outline-2 data-[focus-visible=true]:outline-focus data-[focus-visible=true]:outline-offset-2 text-start'
|
||||
key={header.id}
|
||||
align={header.column.columnDef.meta?.align}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.footer,
|
||||
header.getContext()
|
||||
)}
|
||||
</td>
|
||||
);
|
||||
})}
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
})}
|
||||
</thead>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{state.entries.length > 0 && screenSize === ScreenSize.SM &&
|
||||
<LogbookCard logs={state.entries} onDelete={onDeleteLog} mode='logbook' onOpenCloseForm={onOpenCloseDrawer} />
|
||||
}
|
||||
</div>
|
||||
)}
|
||||
{/* {state.isLoading && !state.alert && (
|
||||
<>
|
||||
<div>
|
||||
<Loading size='xl' />
|
||||
</div>
|
||||
<div>
|
||||
Loading...
|
||||
</div>
|
||||
</>
|
||||
)} */}
|
||||
</div>
|
||||
{logbookContext.state.isDrawerOpen && (
|
||||
<LogbookDrawer
|
||||
onOpenClose={(mode) => onOpenCloseDrawer(mode)}
|
||||
/>
|
||||
)}
|
||||
{state.isConfirmDialogOpen && (
|
||||
<ConfirmationDialog
|
||||
contentText="Are you sure you want to delete the logbook entry?"
|
||||
isLoading={state.isConfirmDialogLoading}
|
||||
isOpen={state.isConfirmDialogOpen}
|
||||
onCancel={onConfirmationDialogCancel}
|
||||
onConfirm={onConfirmationDialogConfirm}
|
||||
title="Confirm Delete"
|
||||
/>
|
||||
)}
|
||||
{/* {state.isTracksOpen &&
|
||||
<LogTracks
|
||||
isDrawerOpen={state.isTracksOpen}
|
||||
mode={state.tracksMode}
|
||||
onOpenClose={(mode) => onOpenCloseTracks(mode)}
|
||||
selectedLogId={logbookContext.state.selectedLogId}
|
||||
/>
|
||||
} */}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Logbook;
|
||||
29
client/src/components/logbook/LogbookEntry.interface.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Pilot } from "../pilots/Pilot.interface";
|
||||
|
||||
export interface LogbookEntry {
|
||||
id: string;
|
||||
pilot: Pilot;
|
||||
date: string;
|
||||
aircraftMakeModel: string;
|
||||
aircraftIdentity: string;
|
||||
routeFrom: string;
|
||||
routeTo: string;
|
||||
durationOfFlight: number | null;
|
||||
singleEngineLand: number | null;
|
||||
simulatorAtd: number | null;
|
||||
landingsDay: number | null;
|
||||
landingsNight: number | null;
|
||||
instrumentActual: number | null;
|
||||
instrumentSimulated: number | null;
|
||||
instrumentApproaches: number | null;
|
||||
instrumentHolds: number | null;
|
||||
instrumentNavTrack: number | null;
|
||||
groundTrainingReceived: number;
|
||||
flightTrainingReceived: number;
|
||||
crossCountry: number | null;
|
||||
night: number | null;
|
||||
solo: number | null;
|
||||
pilotInCommand: number | null;
|
||||
tracks: [];
|
||||
notes: string;
|
||||
}
|
||||
13
client/src/components/logbook/LogbookState.interface.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ColumnDef } from '@noahspan/noahspan-components';
|
||||
import { FormMode } from '../../enums/formMode';
|
||||
import { Alert } from '../../interfaces/Alert.interface';
|
||||
import { LogbookEntry } from './LogbookEntry.interface';
|
||||
|
||||
export interface LogbookState {
|
||||
alert: Alert | undefined;
|
||||
columns: ColumnDef<LogbookEntry>[];
|
||||
entries: LogbookEntry[];
|
||||
isConfirmDialogLoading: boolean;
|
||||
isConfirmDialogOpen: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
290
client/src/components/logbook/columns.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import {
|
||||
CellContext,
|
||||
ColumnDef,
|
||||
HeaderContext,
|
||||
} from '@noahspan/noahspan-components';
|
||||
import { LogbookEntry } from './LogbookEntry.interface';
|
||||
|
||||
const columnTotal = (info: HeaderContext<LogbookEntry, unknown>): number => {
|
||||
const blah = info.table
|
||||
const values: number[] = info.table.getPaginationRowModel().rows.map((row: any) => Number(row.getValue(info.column.id))).filter((value: any) => !Number.isNaN(value));
|
||||
let total: number = 0;
|
||||
|
||||
if (values.length > 0) {
|
||||
total = values.reduce((accumulator, currentValue) => accumulator + currentValue, total)
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
const pilotName: ColumnDef<LogbookEntry> = {
|
||||
id: 'pilotName',
|
||||
accessorKey: 'pilot',
|
||||
header: 'Pilot',
|
||||
footer: 'PAGE TOTALS',
|
||||
cell: (info: CellContext<LogbookEntry, unknown>) => {
|
||||
const pilot: any = info.getValue();
|
||||
|
||||
return pilot.name
|
||||
}
|
||||
}
|
||||
const date: ColumnDef<LogbookEntry> = {
|
||||
id: 'date',
|
||||
accessorKey: 'date',
|
||||
header: 'Date',
|
||||
cell: (info: CellContext<LogbookEntry, unknown>) => {
|
||||
const date = new Date(info.getValue() as string);
|
||||
const formattedDate = `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`
|
||||
|
||||
return formattedDate;
|
||||
}
|
||||
}
|
||||
const aircraftMakeModel: ColumnDef<LogbookEntry> = {
|
||||
id: 'aircraftMakeModel',
|
||||
accessorKey: 'aircraftMakeModel',
|
||||
header: 'Aircraft Make & Model'
|
||||
}
|
||||
const route: ColumnDef<LogbookEntry> = {
|
||||
id: 'route',
|
||||
header: 'Route of Flight',
|
||||
meta: {
|
||||
headerAlign: 'center'
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
id: 'routeFrom',
|
||||
accessorKey: 'routeFrom',
|
||||
header: 'From'
|
||||
},
|
||||
{
|
||||
id: 'routeTo',
|
||||
accessorKey: 'routeTo',
|
||||
header: 'To'
|
||||
}
|
||||
]
|
||||
}
|
||||
const durationOfFlight: ColumnDef<LogbookEntry> = {
|
||||
id: 'durationOfFlight',
|
||||
accessorKey: 'durationOfFlight',
|
||||
header: 'Duration Of Flight',
|
||||
meta: {
|
||||
align: 'right',
|
||||
headerAlign: 'right'
|
||||
},
|
||||
cell: (info: CellContext<LogbookEntry, unknown>) =>
|
||||
info.getValue() ? parseFloat(info.getValue() as string).toFixed(1) : '',
|
||||
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : ''
|
||||
}
|
||||
const notes: ColumnDef<LogbookEntry> = {
|
||||
id: 'notes',
|
||||
accessorKey: 'notes',
|
||||
header: 'Notes'
|
||||
}
|
||||
|
||||
export const unauthColumns: ColumnDef<LogbookEntry>[] = [
|
||||
pilotName,
|
||||
date,
|
||||
aircraftMakeModel,
|
||||
route,
|
||||
durationOfFlight,
|
||||
notes
|
||||
]
|
||||
|
||||
export const authColumns: ColumnDef<LogbookEntry>[] = [
|
||||
pilotName,
|
||||
date,
|
||||
aircraftMakeModel,
|
||||
{
|
||||
id: 'aircraftIdentity',
|
||||
accessorKey: 'aircraftIdentity',
|
||||
header: 'Aircraft Identity',
|
||||
},
|
||||
route,
|
||||
durationOfFlight,
|
||||
{
|
||||
id: 'singleEngineLand',
|
||||
accessorKey: 'singleEngineLand',
|
||||
header: 'Single Engine Land',
|
||||
meta: {
|
||||
align: 'right',
|
||||
headerAlign: 'right'
|
||||
},
|
||||
cell: (info: CellContext<LogbookEntry, unknown>) =>
|
||||
info.getValue() ? parseFloat(info.getValue() as string).toFixed(1) : '',
|
||||
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : ''
|
||||
},
|
||||
{
|
||||
id: 'landings',
|
||||
header: 'Landings',
|
||||
meta: {
|
||||
headerAlign: 'center'
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
id: 'landingsDay',
|
||||
accessorKey: 'landingsDay',
|
||||
header: 'Day',
|
||||
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info) : '',
|
||||
meta: {
|
||||
align: 'right',
|
||||
headerAlign: 'right'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'landingsNight',
|
||||
accessorKey: 'landingsNight',
|
||||
header: 'Night',
|
||||
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info) : '',
|
||||
meta: {
|
||||
align: 'right',
|
||||
headerAlign: 'right'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'instrument',
|
||||
header: 'Instrument',
|
||||
meta: {
|
||||
headerAlign: 'center'
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
id: 'instrumentActual',
|
||||
accessorKey: 'instrumentActual',
|
||||
header: 'Actual',
|
||||
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
|
||||
meta: {
|
||||
align: 'right',
|
||||
headerAlign: 'right'
|
||||
},
|
||||
cell: (info: CellContext<LogbookEntry, unknown>) =>
|
||||
info.getValue() ? parseFloat(info.getValue() as string).toFixed(1) : '',
|
||||
},
|
||||
{
|
||||
id: 'instrumentSimulated',
|
||||
accessorKey: 'instrumentSimulated',
|
||||
header: 'Simulated',
|
||||
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
|
||||
meta: {
|
||||
align: 'right',
|
||||
headerAlign: 'right'
|
||||
},
|
||||
cell: (info: CellContext<LogbookEntry, unknown>) =>
|
||||
info.getValue() ? parseFloat(info.getValue() as string).toFixed(1) : ''
|
||||
},
|
||||
{
|
||||
id: 'instrumentApproaches',
|
||||
accessorKey: 'instrumentApproaches',
|
||||
header: 'Approaches',
|
||||
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
|
||||
meta: {
|
||||
align: 'right',
|
||||
headerAlign: 'right'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'instrumentHolds',
|
||||
accessorKey: 'instrumentHolds',
|
||||
header: 'Holds',
|
||||
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
|
||||
meta: {
|
||||
align: 'right',
|
||||
headerAlign: 'right'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'instrumentNavTrack',
|
||||
accessorKey: 'instrumentNavTrack',
|
||||
header: 'Nav/Track',
|
||||
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
|
||||
meta: {
|
||||
align: 'right',
|
||||
headerAlign: 'right'
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'experienceTraining',
|
||||
header: 'Type of pilot experience or training',
|
||||
meta: {
|
||||
headerAlign: 'center'
|
||||
},
|
||||
columns: [
|
||||
{
|
||||
id: 'groundTrainingReceived',
|
||||
accessorKey: 'groundTrainingReceived',
|
||||
header: 'Ground Training Received',
|
||||
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
|
||||
meta: {
|
||||
align: 'right',
|
||||
headerAlign: 'right'
|
||||
},
|
||||
cell: (info: CellContext<LogbookEntry, unknown>) =>
|
||||
info.getValue() ? parseFloat(info.getValue() as string).toFixed(1) : ''
|
||||
},
|
||||
{
|
||||
id: 'flightTrainingReceived',
|
||||
accessorKey: 'flightTrainingReceived',
|
||||
header: 'Flight Training Received',
|
||||
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
|
||||
meta: {
|
||||
align: 'right',
|
||||
headerAlign: 'right'
|
||||
},
|
||||
cell: (info: CellContext<LogbookEntry, unknown>) =>
|
||||
info.getValue() ? parseFloat(info.getValue() as string).toFixed(1) : ''
|
||||
},
|
||||
{
|
||||
id: 'crossCountry',
|
||||
accessorKey: 'crossCountry',
|
||||
header: 'Cross Country',
|
||||
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
|
||||
meta: {
|
||||
align: 'right',
|
||||
headerAlign: 'right'
|
||||
},
|
||||
cell: (info: CellContext<LogbookEntry, unknown>) =>
|
||||
info.getValue() ? parseFloat(info.getValue() as string).toFixed(1) : ''
|
||||
},
|
||||
{
|
||||
id: 'night',
|
||||
accessorKey: 'night',
|
||||
header: 'Night',
|
||||
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
|
||||
meta: {
|
||||
align: 'right',
|
||||
headerAlign: 'right'
|
||||
},
|
||||
cell: (info: CellContext<LogbookEntry, unknown>) =>
|
||||
info.getValue() ? parseFloat(info.getValue() as string).toFixed(1) : ''
|
||||
},
|
||||
{
|
||||
id: 'solo',
|
||||
accessorKey: 'solo',
|
||||
header: 'Solo',
|
||||
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
|
||||
meta: {
|
||||
align: 'right',
|
||||
headerAlign: 'right'
|
||||
},
|
||||
cell: (info: CellContext<LogbookEntry, unknown>) =>
|
||||
info.getValue() ? parseFloat(info.getValue() as string).toFixed(1) : ''
|
||||
},
|
||||
{
|
||||
id: 'pilotInCommand',
|
||||
accessorKey: 'pilotInCommand',
|
||||
header: 'Pilot In Command',
|
||||
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
|
||||
meta: {
|
||||
align: 'right',
|
||||
headerAlign: 'right'
|
||||
},
|
||||
cell: (info: CellContext<LogbookEntry, unknown>) =>
|
||||
info.getValue() ? parseFloat(info.getValue() as string).toFixed(1) : ''
|
||||
}
|
||||
]
|
||||
},
|
||||
notes
|
||||
]
|
||||
70
client/src/components/logbook/reducer.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { ColumnDef } from '@noahspan/noahspan-components';
|
||||
import { FormMode } from '../../enums/formMode';
|
||||
import { Alert } from '../../interfaces/Alert.interface';
|
||||
import { LogbookEntry } from './LogbookEntry.interface';
|
||||
import { LogbookState } from './LogbookState.interface';
|
||||
|
||||
type Action =
|
||||
| { type: 'SET_COLUMNS'; payload: ColumnDef<LogbookEntry>[] }
|
||||
| { type: 'SET_IS_CONFIRMATION_DIALOG_OPEN'; payload: boolean }
|
||||
| { type: 'SET_ENTRIES'; payload: LogbookEntry[] }
|
||||
| { type: 'SET_ALERT'; payload: Alert | undefined }
|
||||
| { type: 'SET_IS_CONFIRMATION_DIALOG_LOADING'; payload: boolean }
|
||||
| { type: 'SET_IS_LOADING'; payload: boolean };
|
||||
|
||||
|
||||
export const initialState: LogbookState = {
|
||||
alert: undefined,
|
||||
columns: [],
|
||||
entries: [],
|
||||
isConfirmDialogLoading: false,
|
||||
isConfirmDialogOpen: false,
|
||||
isLoading: false
|
||||
};
|
||||
|
||||
export const reducer = (
|
||||
state: LogbookState,
|
||||
action: Action
|
||||
): LogbookState => {
|
||||
switch (action.type) {
|
||||
case 'SET_COLUMNS': {
|
||||
return {
|
||||
...state,
|
||||
columns: action.payload
|
||||
}
|
||||
}
|
||||
case 'SET_IS_CONFIRMATION_DIALOG_OPEN': {
|
||||
return {
|
||||
...state,
|
||||
isConfirmDialogOpen: action.payload,
|
||||
};
|
||||
}
|
||||
case 'SET_ENTRIES': {
|
||||
return {
|
||||
...state,
|
||||
entries: action.payload
|
||||
};
|
||||
}
|
||||
case 'SET_ALERT': {
|
||||
return {
|
||||
...state,
|
||||
alert: action.payload
|
||||
};
|
||||
}
|
||||
case 'SET_IS_CONFIRMATION_DIALOG_LOADING': {
|
||||
return {
|
||||
...state,
|
||||
isConfirmDialogLoading: action.payload
|
||||
};
|
||||
}
|
||||
case 'SET_IS_LOADING': {
|
||||
return {
|
||||
...state,
|
||||
isLoading: action.payload
|
||||
};
|
||||
}
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
79
client/src/components/logbookCard/LogbookCard.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import { Accordion, AccordionItem, Card, CardBody, CardHeader } from '@heroui/react'
|
||||
import { LogbookCardProps } from "./LogbookCardProps.interface";
|
||||
import TrackMap from "../trackMap/TrackMap";
|
||||
|
||||
const LogbookCard = ({ logs, mode, onDelete, onOpenCloseForm }: LogbookCardProps) => {
|
||||
return (
|
||||
<div>
|
||||
{logs.map((log) => {
|
||||
console.log(log)
|
||||
const date = new Date(log.date);
|
||||
const formattedDate: string = `${date.getMonth()}/${date.getDate()}/${date.getFullYear()}`;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Card className='p-4' key={log.id}>
|
||||
<CardHeader>
|
||||
<h2 className='font-bold text-2xl'>{formattedDate}</h2>
|
||||
</CardHeader>
|
||||
<CardBody>
|
||||
{mode === 'flights' && log.tracks && log.tracks.length > 0 &&
|
||||
<div className='mb-5'>
|
||||
<TrackMap
|
||||
height='400px'
|
||||
logId={log.id}
|
||||
tracks={log.tracks}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
<Accordion variant='bordered'>
|
||||
<AccordionItem key='1' title='Details'>
|
||||
<div className='grid grid-cols-12 gap-3 mr-[30%] ml-[30%] mt-4 mb-4'>
|
||||
<div className='col-span-6 font-bold'>
|
||||
<span>Aircraft Make and Model</span>
|
||||
</div>
|
||||
<div className='col-span-6'>
|
||||
<span>{log.aircraftMakeModel}</span>
|
||||
</div>
|
||||
<div className='col-span-6 font-bold'>
|
||||
<span>Route From</span>
|
||||
</div>
|
||||
<div className='col-span-6'>
|
||||
<span>{log.routeFrom}</span>
|
||||
</div>
|
||||
<div className='col-span-6 font-bold'>
|
||||
<span>Route To</span>
|
||||
</div>
|
||||
<div className='col-span-6'>
|
||||
<span>{log.routeTo}</span>
|
||||
</div>
|
||||
<div className='col-span-6 font-bold'>
|
||||
<span>Duration Of Flight</span>
|
||||
</div>
|
||||
<div className='col-span-6'>
|
||||
<span>{log.durationOfFlight}</span>
|
||||
</div>
|
||||
{log.notes &&
|
||||
<>
|
||||
<div className='col-span-6 font-bold'>
|
||||
<span>Notes</span>
|
||||
</div>
|
||||
<div className='col-span-6'>
|
||||
<span>{log.notes}</span>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</AccordionItem>
|
||||
</Accordion>
|
||||
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LogbookCard;
|
||||
@@ -0,0 +1,9 @@
|
||||
import { FormMode } from "../../enums/formMode";
|
||||
import { LogbookEntry } from "../logbook/LogbookEntry.interface";
|
||||
|
||||
export interface LogbookCardProps {
|
||||
logs: LogbookEntry[];
|
||||
mode: 'flights' | 'logbook';
|
||||
onDelete?: (entryId: string) => void;
|
||||
onOpenCloseForm?: (formMode: FormMode, id: string) => void;
|
||||
}
|
||||
175
client/src/components/logbookDrawer/LogbookDrawer.tsx
Normal file
@@ -0,0 +1,175 @@
|
||||
import { Alert, Button, Drawer, DrawerBody, DrawerContent, DrawerFooter, DrawerHeader, Tab, Tabs } from "@heroui/react";
|
||||
import { LogbookDrawerProps } from "./LogbookDrawerProps.interface";
|
||||
import LogForm from "../logForm/LogForm";
|
||||
import TracksForm from "../tracksForm/TracksForm";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { faClock, faMapLocationDot, faSave, faXmark } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FormProvider, useForm } from "react-hook-form";
|
||||
import { FormMode } from "../../enums/formMode";
|
||||
import httpClient from "../../httpClient/httpClient";
|
||||
import { AxiosError } from "axios";
|
||||
import { useLogbookContext } from "../../hooks/logbookContext/UseLogbookContext";
|
||||
import { Key, useState } from "react";
|
||||
|
||||
const LogbookDrawer = ({ onOpenClose }: LogbookDrawerProps) => {
|
||||
const [activeTab, setActiveTab] = useState<Key>('time');
|
||||
const defaultValues = {
|
||||
pilotId: '',
|
||||
date: null,
|
||||
aircraftMakeModel: '',
|
||||
aircraftIdentity: '',
|
||||
routeFrom: '',
|
||||
routeTo: '',
|
||||
durationOfFlight: null,
|
||||
singleEngineLand: null,
|
||||
simulatorAtd: null,
|
||||
landingsDay: null,
|
||||
landingsNight: null,
|
||||
groundTrainingReceived: null,
|
||||
flightTrainingReceived: null,
|
||||
crossCountry: null,
|
||||
night: null,
|
||||
solo: null,
|
||||
pilotInCommand: null,
|
||||
instrumentActual: null,
|
||||
instrumentSimulated: null,
|
||||
instrumentApproaches: null,
|
||||
instrumentHolds: null,
|
||||
instrumentNavTrack: null,
|
||||
notes: ''
|
||||
};
|
||||
const methods = useForm();
|
||||
const logbookContext = useLogbookContext()
|
||||
|
||||
const onCancel = () => {
|
||||
methods.reset(defaultValues);
|
||||
logbookContext.dispatch({ type: 'SET_IS_FORM_DISABLED', payload: false });
|
||||
onOpenClose(FormMode.CANCEL);
|
||||
};
|
||||
|
||||
const onSubmit = async (data: unknown) => {
|
||||
console.log(data)
|
||||
try {
|
||||
logbookContext.dispatch({ type: 'SET_IS_FORM_LOADING', payload: true });
|
||||
|
||||
if (!logbookContext.state.selectedLogId) {
|
||||
await httpClient.post(`api/logs`, data);
|
||||
} else {
|
||||
await httpClient.put(`api/logs/${logbookContext.state.selectedLogId}`, data);
|
||||
}
|
||||
|
||||
methods.reset(defaultValues);
|
||||
logbookContext.dispatch({ type: 'SET_IS_FORM_DISABLED', payload: false });
|
||||
logbookContext.dispatch({ type: 'SET_FORM_MODE', payload: FormMode.CANCEL });
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError;
|
||||
|
||||
logbookContext.dispatch({ type: 'SET_FORM_ALERT', payload: { severity: 'danger', message: axiosError.message }});
|
||||
} finally {
|
||||
logbookContext.dispatch({ type: 'SET_IS_FORM_LOADING', payload: false });
|
||||
}
|
||||
};
|
||||
|
||||
const onSelectedKeyChanged = (key: React.Key) => {
|
||||
setActiveTab(key)
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
closeButton={
|
||||
<Button isIconOnly>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</Button>
|
||||
}
|
||||
isOpen={logbookContext.state.isDrawerOpen}
|
||||
onClose={onCancel}
|
||||
>
|
||||
<DrawerContent>
|
||||
<FormProvider {...methods}>
|
||||
<form className='prose max-w-none' onSubmit={methods.handleSubmit(onSubmit)} style={{ paddingBottom: '50px' }}>
|
||||
<DrawerHeader>
|
||||
{`${logbookContext.state.formMode.toString().toLowerCase().charAt(0).toUpperCase() + logbookContext.state.formMode.toString().slice(1).toLowerCase()} Entry`}
|
||||
</DrawerHeader>
|
||||
<DrawerBody>
|
||||
{logbookContext.state.formAlert && (
|
||||
<div className='col-span-12'>
|
||||
<Alert
|
||||
onClose={() =>
|
||||
logbookContext.dispatch({ type: 'SET_FORM_ALERT', payload: undefined })
|
||||
}
|
||||
color={logbookContext.state.formAlert.severity}
|
||||
title={logbookContext.state.formAlert.message}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Tabs
|
||||
color='default'
|
||||
fullWidth={true}
|
||||
onSelectionChange={onSelectedKeyChanged}
|
||||
selectedKey={activeTab as string}
|
||||
variant='solid'
|
||||
>
|
||||
<Tab
|
||||
key='time'
|
||||
title={
|
||||
<div className="flex items-center space-x-2">
|
||||
<FontAwesomeIcon icon={faClock} />
|
||||
<span>Time</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<LogForm />
|
||||
</Tab>
|
||||
{logbookContext.state.formMode !== FormMode.ADD &&
|
||||
<Tab
|
||||
key='tracks'
|
||||
title={
|
||||
<div className="flex items-center space-x-2">
|
||||
<FontAwesomeIcon icon={faMapLocationDot} />
|
||||
<span>Tracks</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TracksForm />
|
||||
</Tab>
|
||||
}
|
||||
</Tabs>
|
||||
</DrawerBody>
|
||||
{activeTab !== 'tracks' &&
|
||||
<DrawerFooter>
|
||||
<div className='grid grid-cols-12 gap-3'>
|
||||
<div className='col-span-12 justify-self-end self-center'>
|
||||
<Button
|
||||
disabled={
|
||||
logbookContext.state.isFormDisabled && logbookContext.state.formMode.toString() !== FormMode.VIEW
|
||||
? logbookContext.state.isFormDisabled
|
||||
: false
|
||||
}
|
||||
startContent={<FontAwesomeIcon icon={faXmark} />}
|
||||
onPress={onCancel}
|
||||
>
|
||||
{logbookContext.state.formMode.toString() !== FormMode.VIEW ? 'Cancel' : 'Close'}
|
||||
</Button>
|
||||
{logbookContext.state.formMode.toString() !== FormMode.VIEW && (
|
||||
<Button
|
||||
className='ml-[10px]'
|
||||
color='primary'
|
||||
disabled={logbookContext.state.isFormDisabled}
|
||||
startContent={<FontAwesomeIcon icon={faSave} />}
|
||||
type="submit"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DrawerFooter>
|
||||
}
|
||||
</form>
|
||||
</FormProvider>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
export default LogbookDrawer
|
||||
@@ -0,0 +1,5 @@
|
||||
import { FormMode } from "../../enums/formMode";
|
||||
|
||||
export interface LogbookDrawerProps {
|
||||
onOpenClose: (mode: FormMode) => void;
|
||||
}
|
||||
26
client/src/components/pilotCard/PilotCard.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
import { Card, CardActions, CardBody, CardHeader} from "@noahspan/noahspan-components"
|
||||
import { PilotCardProps } from "./PilotCardProps.interface"
|
||||
import ActionMenu from "../actionMenu/ActionMenu"
|
||||
|
||||
const PilotCard = ({ pilots, onDelete, onOpenCloseForm }: PilotCardProps) => {
|
||||
return (
|
||||
<div>
|
||||
{pilots.map((pilot) => {
|
||||
return (
|
||||
<div>
|
||||
<Card key={pilot.id}>
|
||||
<CardBody>
|
||||
<CardHeader>{pilot.name}</CardHeader>
|
||||
<CardActions>
|
||||
<ActionMenu id={pilot.id} onDelete={onDelete} onOpenCloseForm={onOpenCloseForm} />
|
||||
</CardActions>
|
||||
</CardBody>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PilotCard;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { FormMode } from "../../enums/formMode";
|
||||
import { Pilot } from "../pilots/Pilot.interface";
|
||||
|
||||
export interface PilotCardProps {
|
||||
pilots: Pilot[];
|
||||
onDelete: (entryId: string) => void;
|
||||
onOpenCloseForm: (formMode: FormMode, id: string) => void;
|
||||
}
|
||||
8
client/src/components/pilotForm/IPilotFormProps.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { FormMode } from '../../enums/formMode';
|
||||
|
||||
export interface IPilotFormProps {
|
||||
isDrawerOpen: boolean;
|
||||
mode: FormMode;
|
||||
onOpenClose: (mode: FormMode) => void;
|
||||
pilotId?: string;
|
||||
}
|
||||
383
client/src/components/pilotForm/PilotForm.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
import { Key, useEffect, useState } from 'react';
|
||||
import { useForm, Controller, FormProvider } from 'react-hook-form';
|
||||
import { IPilotFormProps } from './IPilotFormProps';
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
import { FormMode } from '../../enums/formMode';
|
||||
import { Person } from '@microsoft/microsoft-graph-types';
|
||||
import PilotFormCertificates from '../pilotFormCertificates/PilotFormCertificates';
|
||||
import PilotFormEndorsements from '../pilotFormEndorsements/PilotFormEndorsements';
|
||||
import PilotFormMedical from '../pilotFormMedical/PilotFormMedical';
|
||||
import { useOidc } from '../../auth/oidcConfig';
|
||||
import httpClient from '../../httpClient/httpClient';
|
||||
import { Button, Drawer, DrawerHeader, DrawerContent, DrawerBody, DrawerFooter, Input, Autocomplete, AutocompleteItem, SelectItem, Select } from '@heroui/react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faSave, faXmark } from '@fortawesome/free-solid-svg-icons';
|
||||
import { states } from './states';
|
||||
|
||||
const PilotForm: React.FC<IPilotFormProps> = ({
|
||||
pilotId,
|
||||
isDrawerOpen,
|
||||
mode,
|
||||
onOpenClose
|
||||
}: IPilotFormProps) => {
|
||||
const [peoplePickerValue, setPeoplePickerValue] = useState<string>('');
|
||||
const [peoplePickerResults, setPeoplePickerResults] = useState<Person[]>([]);
|
||||
const [isPeoplePickerLoading, setIsPeoplePickerLoading] =
|
||||
useState<boolean>(false);
|
||||
const [selectedPerson, setSelectedPerson] = useState<Person>({
|
||||
displayName: ''
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState<boolean>(false);
|
||||
const { isUserLoggedIn } = useOidc();
|
||||
const defaultValues = {
|
||||
name: '',
|
||||
address: '',
|
||||
city: '',
|
||||
state: '',
|
||||
postalCode: '',
|
||||
email: '',
|
||||
phone: '',
|
||||
userId: ''
|
||||
};
|
||||
const methods = useForm({
|
||||
defaultValues: defaultValues
|
||||
});
|
||||
const [isDisabled, setIsDisabled] = useState<boolean>(false);
|
||||
const [isError, setIsError] = useState<boolean>(false);
|
||||
|
||||
const onPeoplePickerSearch = async (
|
||||
value: string
|
||||
) => {
|
||||
setIsPeoplePickerLoading(true);
|
||||
setPeoplePickerValue(value)
|
||||
|
||||
try {
|
||||
if (value !== '') {
|
||||
const searchString: string = value;
|
||||
const response: AxiosResponse = await httpClient.get(
|
||||
`api/msgraph/search?search=${searchString}`
|
||||
);
|
||||
|
||||
setPeoplePickerResults(response.data);
|
||||
} else {
|
||||
setPeoplePickerResults([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setIsPeoplePickerLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onPersonSelected = (userPrincipalName: string) => {
|
||||
const person: Person | undefined = peoplePickerResults.find((person) => person.userPrincipalName === userPrincipalName as string);
|
||||
|
||||
methods.setValue('name', person?.displayName!);
|
||||
methods.setValue('userId', person?.userPrincipalName!);
|
||||
setPeoplePickerValue(person?.displayName!);
|
||||
setPeoplePickerResults([])
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
methods.reset(defaultValues);
|
||||
onOpenClose(FormMode.CANCEL);
|
||||
setSelectedPerson({ userPrincipalName: '', displayName: '' });
|
||||
setIsDisabled(false)
|
||||
};
|
||||
|
||||
const onSubmit = async (data: unknown) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
if (!pilotId) {
|
||||
await httpClient.post(`api/pilots`, data);
|
||||
} else {
|
||||
await httpClient.put(`api/pilots/pilot/${pilotId}`, data)
|
||||
}
|
||||
|
||||
methods.reset(defaultValues)
|
||||
onOpenClose(FormMode.CANCEL);
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError;
|
||||
const responseData = axiosError.response?.data as any;
|
||||
|
||||
console.log(responseData.message);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === FormMode.VIEW) {
|
||||
setIsDisabled(true);
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
useEffect(() => {
|
||||
const getPilot = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
const response: AxiosResponse = await httpClient.get(
|
||||
`api/pilots/${pilotId}`
|
||||
);
|
||||
const pilot = response.data;
|
||||
|
||||
setPeoplePickerValue(pilot.name);
|
||||
methods.reset(pilot);
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (pilotId) {
|
||||
getPilot();
|
||||
}
|
||||
}, [pilotId]);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
closeButton={
|
||||
<Button isIconOnly>
|
||||
<FontAwesomeIcon icon={faXmark} />
|
||||
</Button>
|
||||
}
|
||||
isOpen={isDrawerOpen}
|
||||
placement='right'
|
||||
data-testid="pilot-drawer"
|
||||
onClose={onCancel}
|
||||
size='xl'
|
||||
>
|
||||
<DrawerContent>
|
||||
<FormProvider {...methods}>
|
||||
<form className='prose max-w-none' onSubmit={methods.handleSubmit(onSubmit)} style={{ paddingBottom: '50px' }}>
|
||||
<DrawerHeader>
|
||||
{`${mode.toString().toLowerCase().charAt(0).toUpperCase() + mode.toString().slice(1).toLowerCase()} Pilot`}
|
||||
</DrawerHeader>
|
||||
<DrawerBody>
|
||||
<div className='grid grid-cols-12 gap-3'>
|
||||
<div className='col-span-3 self-center'>
|
||||
<h6>Name *</h6>
|
||||
</div>
|
||||
<div className='col-span-9'>
|
||||
<Autocomplete
|
||||
inputValue={peoplePickerValue}
|
||||
isLoading={isPeoplePickerLoading}
|
||||
items={peoplePickerResults}
|
||||
onInputChange={onPeoplePickerSearch}
|
||||
onSelectionChange={(key: Key | null) => onPersonSelected(key as string)}
|
||||
>
|
||||
{peoplePickerResults.map((person: Person) => (
|
||||
<AutocompleteItem key={person.userPrincipalName}>
|
||||
{person.displayName}
|
||||
</AutocompleteItem>
|
||||
))}
|
||||
</Autocomplete>
|
||||
</div>
|
||||
{isUserLoggedIn &&
|
||||
<>
|
||||
<div className='col-span-3 self-center'>
|
||||
<span>Address *</span>
|
||||
</div>
|
||||
<div className='col-span-9'>
|
||||
<Controller
|
||||
name="address"
|
||||
control={methods.control}
|
||||
rules={{ required: 'An address is required' }}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Input
|
||||
disabled={isDisabled}
|
||||
color={methods.formState.errors.address ? 'danger' : undefined}
|
||||
errorMessage={
|
||||
methods.formState.errors.address
|
||||
? methods.formState.errors.address.message
|
||||
: undefined
|
||||
}
|
||||
fullWidth={true}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='col-span-3 self-center'>
|
||||
<h6>City *</h6>
|
||||
</div>
|
||||
<div className='col-span-9'>
|
||||
<Controller
|
||||
name="city"
|
||||
control={methods.control}
|
||||
rules={{ required: 'A city is required' }}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Input
|
||||
disabled={isDisabled}
|
||||
color={methods.formState.errors.city ? 'danger' : undefined}
|
||||
errorMessage={
|
||||
methods.formState.errors.city
|
||||
? methods.formState.errors.city.message
|
||||
: undefined
|
||||
}
|
||||
fullWidth={true}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='col-span-3 self-center'>
|
||||
<h6>State *</h6>
|
||||
</div>
|
||||
<div className='col-span-9'>
|
||||
<Controller
|
||||
name="state"
|
||||
control={methods.control}
|
||||
rules={{ required: 'A state must be selected' }}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Select
|
||||
onChange={onChange}
|
||||
selectedKeys={[value]}
|
||||
>
|
||||
{states.map((state) => (
|
||||
<SelectItem key={state.value}>{state.label}</SelectItem>
|
||||
))}
|
||||
</Select>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='col-span-3 self-center'>
|
||||
<h6>Postal Code *</h6>
|
||||
</div>
|
||||
<div className='col-span-9'>
|
||||
<Controller
|
||||
name="postalCode"
|
||||
control={methods.control}
|
||||
rules={{ required: 'A postal code is required' }}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Input
|
||||
disabled={isDisabled}
|
||||
color={methods.formState.errors.postalCode ? 'danger' : undefined}
|
||||
errorMessage={
|
||||
methods.formState.errors.postalCode
|
||||
? methods.formState.errors.postalCode.message
|
||||
: undefined
|
||||
}
|
||||
fullWidth={true}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='col-span-3 self-center'>
|
||||
<h6>Email</h6>
|
||||
</div>
|
||||
<div className='col-span-9'>
|
||||
<Controller
|
||||
name="email"
|
||||
control={methods.control}
|
||||
rules={{
|
||||
pattern: {
|
||||
value: /^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i,
|
||||
message: 'Invalid email address'
|
||||
}
|
||||
}}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Input
|
||||
disabled={isDisabled}
|
||||
color={methods.formState.errors.email ? 'danger' : undefined}
|
||||
errorMessage={
|
||||
methods.formState.errors.email
|
||||
? methods.formState.errors.email.message
|
||||
: undefined
|
||||
}
|
||||
fullWidth={true}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className='col-span-3 self-center'>
|
||||
<h6>Phone Number</h6>
|
||||
</div>
|
||||
<div className='col-span-9'>
|
||||
<Controller
|
||||
name="phone"
|
||||
control={methods.control}
|
||||
rules={{
|
||||
pattern: {
|
||||
value: /^[0-9]{3}[-\s\.][0-9]{3}[-\s\.][0-9]{4}$/i,
|
||||
message: 'Enter phone number as 123-456-7890'
|
||||
}
|
||||
}}
|
||||
render={({ field: { onChange, value } }) => (
|
||||
<Input
|
||||
disabled={isDisabled}
|
||||
color={methods.formState.errors.phone ? 'danger' : undefined}
|
||||
errorMessage={
|
||||
methods.formState.errors.phone
|
||||
? methods.formState.errors.phone.message
|
||||
: undefined
|
||||
}
|
||||
fullWidth={true}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
{/* {isAuthenticated &&
|
||||
<Grid size={12}>
|
||||
<PilotFormMedical
|
||||
isDisabled={isDisabled}
|
||||
/>
|
||||
</Grid>
|
||||
}
|
||||
<Grid size={12}>
|
||||
<PilotFormCertificates isDisabled={isDisabled} mode={mode} />
|
||||
</Grid>
|
||||
<Grid size={12}>
|
||||
<PilotFormEndorsements isDisabled={isDisabled} mode={mode} />
|
||||
</Grid> */}
|
||||
{/* <div className='col-span-12 justify-self-end self-center'> */}
|
||||
|
||||
{/* </div> */}
|
||||
</div>
|
||||
</DrawerBody>
|
||||
<DrawerFooter>
|
||||
<Button
|
||||
disabled={
|
||||
isDisabled && mode.toString() !== FormMode.VIEW
|
||||
? isDisabled
|
||||
: false
|
||||
}
|
||||
startContent={<FontAwesomeIcon icon={faXmark} />}
|
||||
onPress={onCancel}
|
||||
data-testid="pilot-cancel-button"
|
||||
>
|
||||
{mode.toString() !== FormMode.VIEW ? 'Cancel' : 'Close'}
|
||||
</Button>
|
||||
{mode.toString() !== FormMode.VIEW && (
|
||||
<Button
|
||||
color='primary'
|
||||
disabled={isDisabled}
|
||||
startContent={<FontAwesomeIcon icon={faSave} />}
|
||||
type="submit"
|
||||
data-testid="pilot-save-button"
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
)}
|
||||
</DrawerFooter>
|
||||
</form>
|
||||
</FormProvider>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
};
|
||||
|
||||
export default PilotForm;
|
||||
198
client/src/components/pilotForm/states.ts
Normal file
@@ -0,0 +1,198 @@
|
||||
export const states: { label: string; value: string }[] = [
|
||||
{
|
||||
label: 'Alabama',
|
||||
value: 'Alabama'
|
||||
},
|
||||
{
|
||||
label: 'Alaska',
|
||||
value: 'Alaska'
|
||||
},
|
||||
{
|
||||
label: 'Arizona',
|
||||
value: 'Arizona'
|
||||
},
|
||||
{
|
||||
label: 'Arkansas',
|
||||
value: 'Arkansas'
|
||||
},
|
||||
{
|
||||
label: 'California',
|
||||
value: 'California'
|
||||
},
|
||||
{
|
||||
label: 'Colorado',
|
||||
value: 'Colorado'
|
||||
},
|
||||
{
|
||||
label: 'Connecticut',
|
||||
value: 'Connecticut'
|
||||
},
|
||||
{
|
||||
label: 'Delaware',
|
||||
value: 'Deleware'
|
||||
},
|
||||
{
|
||||
label: 'Florida',
|
||||
value: 'Florida'
|
||||
},
|
||||
{
|
||||
label: 'Georgia',
|
||||
value: 'Georgia'
|
||||
},
|
||||
{
|
||||
label: 'Hawaii',
|
||||
value: 'Hawaii'
|
||||
},
|
||||
{
|
||||
label: 'Idaho',
|
||||
value: 'Idaho'
|
||||
},
|
||||
{
|
||||
label: 'Illinois',
|
||||
value: 'Illinois'
|
||||
},
|
||||
{
|
||||
label: 'Indiana',
|
||||
value: 'Indiana'
|
||||
},
|
||||
{
|
||||
label: 'Kansas',
|
||||
value: 'Kansas'
|
||||
},
|
||||
{
|
||||
label: 'Kentucky',
|
||||
value: 'Kentucky'
|
||||
},
|
||||
{
|
||||
label: 'Louisiana',
|
||||
value: 'Louisiana'
|
||||
},
|
||||
{
|
||||
label: 'Maine',
|
||||
value: 'Maine'
|
||||
},
|
||||
{
|
||||
label: 'Maryland',
|
||||
value: 'Maryland'
|
||||
},
|
||||
{
|
||||
label: 'Massachusetts',
|
||||
value: 'Massachusetts'
|
||||
},
|
||||
{
|
||||
label: 'Michigan',
|
||||
value: 'Michigan'
|
||||
},
|
||||
{
|
||||
label: 'Minnesota',
|
||||
value: 'Minnesota'
|
||||
},
|
||||
{
|
||||
label: 'Mississippi',
|
||||
value: 'Mississippi'
|
||||
},
|
||||
{
|
||||
label: 'Missouri',
|
||||
value: 'Missouri'
|
||||
},
|
||||
{
|
||||
label: 'Montana',
|
||||
value: 'Montana'
|
||||
},
|
||||
{
|
||||
label: 'Nebraska',
|
||||
value: 'Nebraska'
|
||||
},
|
||||
{
|
||||
label: 'Nevada',
|
||||
value: 'Nevada'
|
||||
},
|
||||
{
|
||||
label: 'New Hampshire',
|
||||
value: 'New Hampshire'
|
||||
},
|
||||
{
|
||||
label: 'New Jersey',
|
||||
value: 'New Jersey'
|
||||
},
|
||||
{
|
||||
label: 'New Mexico',
|
||||
value: 'New Mexico'
|
||||
},
|
||||
{
|
||||
label: 'New York',
|
||||
value: 'New York'
|
||||
},
|
||||
{
|
||||
label: 'North Carolina',
|
||||
value: 'North Carolina'
|
||||
},
|
||||
{
|
||||
label: 'North Dakota',
|
||||
value: 'North Dakota'
|
||||
},
|
||||
{
|
||||
label: 'Ohio',
|
||||
value: 'Ohio'
|
||||
},
|
||||
{
|
||||
label: 'Oklahoma',
|
||||
value: 'Oklahoma'
|
||||
},
|
||||
{
|
||||
label: 'Oregon',
|
||||
value: 'Oregon'
|
||||
},
|
||||
{
|
||||
label: 'Pennsylvania',
|
||||
value: 'Pennsylvania'
|
||||
},
|
||||
{
|
||||
label: 'Rhode Island',
|
||||
value: 'Rhode Island'
|
||||
},
|
||||
{
|
||||
label: 'South Carolina',
|
||||
value: 'South Carolina'
|
||||
},
|
||||
{
|
||||
label: 'South Dakota',
|
||||
value: 'South Dakota'
|
||||
},
|
||||
{
|
||||
label: 'Tennessee',
|
||||
value: 'Tennessee'
|
||||
},
|
||||
{
|
||||
label: 'Texas',
|
||||
value: 'Texas'
|
||||
},
|
||||
{
|
||||
label: 'Utah',
|
||||
value: 'Utah'
|
||||
},
|
||||
{
|
||||
label: 'Vermont',
|
||||
value: 'Vermont'
|
||||
},
|
||||
{
|
||||
label: 'Virginia',
|
||||
value: 'Virginia'
|
||||
},
|
||||
{
|
||||
label: 'Washington',
|
||||
value: 'Washington'
|
||||
},
|
||||
{
|
||||
label: 'West Virginia',
|
||||
value: 'West Virginia'
|
||||
},
|
||||
{
|
||||
label: 'Wisconsin',
|
||||
value: 'Wisconsin'
|
||||
},
|
||||
{
|
||||
label: 'Wyoming',
|
||||
value: 'Wyoming'
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,153 @@
|
||||
import { PilotFormCertificatesProps } from './PilotFormCertificatesProps.interface';
|
||||
import {
|
||||
Button,
|
||||
DatePicker,
|
||||
Icon,
|
||||
IconButton,
|
||||
IconName,
|
||||
Input,
|
||||
Select
|
||||
} from '@noahspan/noahspan-components';
|
||||
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
|
||||
import { FormMode } from '../../enums/formMode';
|
||||
|
||||
const PilotFormCertificates = ({
|
||||
isDisabled,
|
||||
mode
|
||||
}: PilotFormCertificatesProps ) => {
|
||||
const {
|
||||
control,
|
||||
formState: { errors },
|
||||
} = useFormContext();
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
name: 'certificates',
|
||||
control
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{fields.length > 0 || mode !== FormMode.VIEW &&
|
||||
<div>
|
||||
<h5>Certificates</h5>
|
||||
</div>
|
||||
}
|
||||
{fields.length > 0 && (
|
||||
<>
|
||||
<div>
|
||||
<h6>Type</h6>
|
||||
</div>
|
||||
<div>
|
||||
<h6>Number</h6>
|
||||
</div>
|
||||
<div>
|
||||
<h6>Date of Issue</h6>
|
||||
</div>
|
||||
<div>
|
||||
</div>
|
||||
{fields.map((field, index) => {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Controller
|
||||
name={`certificates.${index}.type`}
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<Select
|
||||
disabled={isDisabled}
|
||||
onChange={onChange}
|
||||
options={
|
||||
[
|
||||
{
|
||||
label: 'Student',
|
||||
value: 'student'
|
||||
},
|
||||
{
|
||||
label: 'Private',
|
||||
value: 'private'
|
||||
},
|
||||
{
|
||||
label: 'Instrument',
|
||||
value: 'instrument'
|
||||
},
|
||||
{
|
||||
label: 'Recreational',
|
||||
value: 'recreational'
|
||||
},
|
||||
{
|
||||
label: 'Sport',
|
||||
value: 'sport'
|
||||
}
|
||||
]
|
||||
}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
name={`certificates.${index}.number`}
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<Input
|
||||
disabled={isDisabled}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
/>
|
||||
)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
name={`certificates.${index}.dateOfIssue`}
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<DatePicker
|
||||
disabled={isDisabled}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<IconButton
|
||||
disabled={isDisabled}
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Icon iconName={IconName.TRASH} size='sm' />
|
||||
</IconButton>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{!isDisabled &&
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
append({
|
||||
type: '',
|
||||
number: '',
|
||||
dateOfIssue: null
|
||||
});
|
||||
}}
|
||||
startContent={<Icon iconName={IconName.PLUS} />}
|
||||
>
|
||||
Add Certificate
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PilotFormCertificates;
|
||||
@@ -0,0 +1,6 @@
|
||||
import { FormMode } from "../../enums/formMode";
|
||||
|
||||
export interface PilotFormCertificatesProps {
|
||||
isDisabled: boolean;
|
||||
mode: FormMode;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { PilotFormEndorsementsProps } from './PilotFormEndorsementsProps.interface';
|
||||
import {
|
||||
Button,
|
||||
DatePicker,
|
||||
Icon,
|
||||
IconButton,
|
||||
IconName,
|
||||
Select
|
||||
} from '@noahspan/noahspan-components';
|
||||
import { Controller, useFieldArray, useFormContext } from 'react-hook-form';
|
||||
import { FormMode } from '../../enums/formMode';
|
||||
|
||||
const PilotFormEndorsements = ({
|
||||
mode,
|
||||
isDisabled
|
||||
}: PilotFormEndorsementsProps) => {
|
||||
const {
|
||||
control,
|
||||
formState: { errors },
|
||||
setValue
|
||||
} = useFormContext();
|
||||
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
name: 'endorsements',
|
||||
control
|
||||
});
|
||||
|
||||
return (
|
||||
<div>
|
||||
{fields.length > 0 || mode !== FormMode.VIEW &&
|
||||
<div>
|
||||
<h5>Endorsements</h5>
|
||||
</div>
|
||||
}
|
||||
{fields.length > 0 && (
|
||||
<>
|
||||
<div>
|
||||
<h6>Type</h6>
|
||||
</div>
|
||||
<div>
|
||||
<h6>Date of Issue</h6>
|
||||
</div>
|
||||
<div></div>
|
||||
{fields.map((field, index) => {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
<Controller
|
||||
name={`endorsements.${index}.type`}
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<Select
|
||||
disabled={isDisabled}
|
||||
onChange={onChange}
|
||||
options={
|
||||
[
|
||||
{
|
||||
label: 'Complex',
|
||||
value: 'complex'
|
||||
},
|
||||
{
|
||||
label: 'High Performance',
|
||||
value: 'highPerfomance'
|
||||
},
|
||||
{
|
||||
label: 'High Altitude',
|
||||
value: 'highAltitude'
|
||||
},
|
||||
{
|
||||
label: 'Tailwheel',
|
||||
value: 'tailwheel'
|
||||
}
|
||||
]
|
||||
}
|
||||
value={value ? value : ''}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
name={`endorsements.${index}.dateOfIssue`}
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<DatePicker
|
||||
disabled={isDisabled}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<IconButton
|
||||
disabled={isDisabled}
|
||||
onClick={() => remove(index)}
|
||||
>
|
||||
<Icon iconName={IconName.TRASH} size="sm" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
{!isDisabled &&
|
||||
<div>
|
||||
<Button
|
||||
onClick={() => {
|
||||
append({
|
||||
type: '',
|
||||
dateOfIssue: null
|
||||
});
|
||||
}}
|
||||
startContent={<Icon iconName={IconName.PLUS} />}
|
||||
>
|
||||
Add Endorsement
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PilotFormEndorsements;
|
||||
@@ -0,0 +1,6 @@
|
||||
import { FormMode } from "../../enums/formMode";
|
||||
|
||||
export interface PilotFormEndorsementsProps {
|
||||
isDisabled: boolean;
|
||||
mode: FormMode;
|
||||
}
|
||||
77
client/src/components/pilotFormMedical/PilotFormMedical.tsx
Normal file
@@ -0,0 +1,77 @@
|
||||
import { DatePicker, Select } from '@noahspan/noahspan-components';
|
||||
import { Controller, useFormContext } from "react-hook-form"
|
||||
import { PilotFormMedicalProps } from "./PilotFormMedicalProps.interface";
|
||||
|
||||
const PilotFormMedical = ({ isDisabled }: PilotFormMedicalProps) => {
|
||||
const {
|
||||
control,
|
||||
formState: { errors },
|
||||
setValue
|
||||
} = useFormContext();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<h5>Medical</h5>
|
||||
</div>
|
||||
<div>
|
||||
<h6>Class</h6>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
name="medicalClass"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<Select
|
||||
disabled={isDisabled}
|
||||
onChange={onChange}
|
||||
options={
|
||||
[
|
||||
{
|
||||
label: 'First',
|
||||
value: 'first'
|
||||
},
|
||||
{
|
||||
label: 'Second',
|
||||
value: 'second'
|
||||
},
|
||||
{
|
||||
label: 'Third',
|
||||
value: 'third'
|
||||
},
|
||||
{
|
||||
label: 'Basic Med',
|
||||
value: 'basicMed'
|
||||
}
|
||||
]
|
||||
}
|
||||
value={value ? value : ''}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<h6>Expiration</h6>
|
||||
</div>
|
||||
<div>
|
||||
<Controller
|
||||
name="medicalExpiration"
|
||||
control={control}
|
||||
render={({ field: { onChange, value } }) => {
|
||||
return (
|
||||
<DatePicker
|
||||
disabled={isDisabled}
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
/>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default PilotFormMedical
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface PilotFormMedicalProps {
|
||||
isDisabled: boolean;
|
||||
}
|
||||
10
client/src/components/pilots/Pilot.interface.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export interface Pilot {
|
||||
id: string;
|
||||
name: string;
|
||||
address: string;
|
||||
city: string;
|
||||
state: string;
|
||||
postalCode: string;
|
||||
email: string;
|
||||
phone: string;
|
||||
};
|
||||
267
client/src/components/pilots/Pilots.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
import { useEffect, useReducer } from 'react';
|
||||
import PilotForm from '../pilotForm/PilotForm';
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
import { FormMode } from '../../enums/formMode';
|
||||
import { initialState, reducer } from './reducer';
|
||||
import ConfirmationDialog from '../confirmationDialog/ConfirmationDialog';
|
||||
import PilotCard from '../pilotCard/PilotCard';
|
||||
import { useUserRole } from '../../hooks/userRole/UseUserRole';
|
||||
import { UserRole } from '../../enums/userRole';
|
||||
import httpClient from '../../httpClient/httpClient'
|
||||
import { ScreenSize } from '../../enums/screenSize';
|
||||
import { useBreakpoints } from '../../hooks/useBreakpoints/UseBreakpoints';
|
||||
import { Alert, Button, Dropdown, DropdownItem, DropdownSection, Table, TableHeader, TableBody, TableColumn, TableRow, TableCell, DropdownTrigger, DropdownMenu } from '@heroui/react'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faEllipsisVertical, faPen, faEye, faTrash, faPlus } from '@fortawesome/free-solid-svg-icons'
|
||||
|
||||
const Pilots: React.FC<unknown> = () => {
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const { userRole } = useUserRole();
|
||||
const { screenSize } = useBreakpoints()
|
||||
|
||||
const getPilots = async () => {
|
||||
try {
|
||||
let response: AxiosResponse;
|
||||
|
||||
response = await httpClient.get(
|
||||
`api/pilots`
|
||||
);
|
||||
|
||||
if (response.data.length > 0) {
|
||||
dispatch({ type: 'SET_PILOTS', payload: response.data });
|
||||
|
||||
if (state.alert) {
|
||||
dispatch({ type: 'SET_ALERT', payload: undefined })
|
||||
}
|
||||
} else {
|
||||
dispatch({ type: 'SET_ALERT', payload: { severity: 'default', message: 'No pilots found.' }})
|
||||
dispatch({ type: 'SET_PILOTS', payload: [] });
|
||||
}
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError;
|
||||
|
||||
dispatch({
|
||||
type: 'SET_ALERT',
|
||||
payload: { severity: 'danger', message: `Loading of pilots failed with the following message: ${axiosError.message}`}
|
||||
})
|
||||
} finally {
|
||||
dispatch({ type: 'SET_IS_LOADING', payload: false })
|
||||
}
|
||||
};
|
||||
|
||||
const onOpenClosePilotForm = async (mode: FormMode, pilotId?: string) => {
|
||||
console.log(pilotId)
|
||||
switch (mode) {
|
||||
case FormMode.ADD:
|
||||
case FormMode.EDIT:
|
||||
case FormMode.VIEW:
|
||||
dispatch({
|
||||
type: 'SET_OPEN_CLOSE_ENTRY_FORM',
|
||||
payload: {
|
||||
formMode: mode,
|
||||
selectedPilotId: pilotId,
|
||||
isFormOpen: true
|
||||
}
|
||||
})
|
||||
|
||||
break;
|
||||
case FormMode.CANCEL:
|
||||
dispatch({
|
||||
type: 'SET_OPEN_CLOSE_ENTRY_FORM',
|
||||
payload: {
|
||||
formMode: mode,
|
||||
selectedPilotId: undefined,
|
||||
isFormOpen: false
|
||||
}
|
||||
})
|
||||
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
const onDeletePilot = (pilotId: string) => {
|
||||
dispatch({
|
||||
type: 'SET_DELETE',
|
||||
payload: { isConfirmDialogOpen: true, selectedPilotId: pilotId }
|
||||
});
|
||||
};
|
||||
|
||||
const onConfirmationDialogConfirm = async () => {
|
||||
try {
|
||||
dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: true });
|
||||
|
||||
await httpClient.delete(`api/pilots/${state.selectedPilotId}`);
|
||||
|
||||
dispatch({
|
||||
type: 'SET_DELETE',
|
||||
payload: { isConfirmDialogOpen: false, selectedPilotId: undefined }
|
||||
});
|
||||
await getPilots();
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError;
|
||||
|
||||
dispatch({
|
||||
type: 'SET_ALERT',
|
||||
payload: { severity: 'danger', message: `Loading of logbook entries failed with the following message: ${axiosError.message}`}
|
||||
});
|
||||
} finally {
|
||||
dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: false });
|
||||
}
|
||||
};
|
||||
|
||||
const onConfirmationDialogCancel = () => {
|
||||
dispatch({
|
||||
type: 'SET_DELETE',
|
||||
payload: { isConfirmDialogOpen: false, selectedPilotId: undefined }
|
||||
});
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
id: 'name',
|
||||
name: 'Name'
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
name: 'Actions'
|
||||
}
|
||||
]
|
||||
|
||||
const renderCell = (pilot: any, columnKey: any) => {
|
||||
const cellValue = pilot[columnKey]
|
||||
|
||||
switch (columnKey) {
|
||||
case 'actions': {
|
||||
return (
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Button isIconOnly variant='light' size='lg'>
|
||||
<FontAwesomeIcon icon={faEllipsisVertical} />
|
||||
</Button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu>
|
||||
<DropdownSection showDivider>
|
||||
<DropdownItem
|
||||
key='edit'
|
||||
onPress={() => onOpenClosePilotForm(FormMode.EDIT, pilot.id)}
|
||||
startContent={<FontAwesomeIcon icon={faPen} />}
|
||||
>
|
||||
Edit
|
||||
</DropdownItem>
|
||||
<DropdownItem
|
||||
key='view'
|
||||
onPress={() => onOpenClosePilotForm(FormMode.VIEW, pilot.id)}
|
||||
startContent={<FontAwesomeIcon icon={faEye} />}
|
||||
>
|
||||
View
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
<DropdownSection>
|
||||
<DropdownItem
|
||||
key='Delete'
|
||||
onPress={() => onDeletePilot(pilot.id)}
|
||||
startContent={<FontAwesomeIcon icon={faTrash} />}
|
||||
>
|
||||
Delete
|
||||
</DropdownItem>
|
||||
</DropdownSection>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
)
|
||||
}
|
||||
default: {
|
||||
return cellValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!state.isFormOpen) {
|
||||
getPilots();
|
||||
}
|
||||
}, [state.isFormOpen]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='mr-10 ml-10 grid grid-cols-12'>
|
||||
<div className='prose max-w-none col-span-10 mt-5 mb-5' >
|
||||
<h1>Pilots</h1>
|
||||
</div>
|
||||
<div className='col-span-2 justify-self-end self-center'>
|
||||
{userRole === UserRole.WRITE &&
|
||||
<Button
|
||||
color='primary'
|
||||
onPress={() => onOpenClosePilotForm(FormMode.ADD)}
|
||||
startContent={<FontAwesomeIcon icon={faPlus} />}
|
||||
data-testid="pilot-add-button"
|
||||
>
|
||||
Add Pilot
|
||||
</Button>
|
||||
}
|
||||
</div>
|
||||
{!state.isLoading && state.alert && (
|
||||
<div className='col-span-12'>
|
||||
<Alert
|
||||
onClose={() =>
|
||||
dispatch({ type: 'SET_ALERT', payload: undefined })
|
||||
}
|
||||
color={state.alert.severity}
|
||||
title={state.alert.message}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className='col-span-12'>
|
||||
{state.pilots.length > 0 && screenSize !== ScreenSize.SM &&
|
||||
<Table>
|
||||
<TableHeader columns={columns}>
|
||||
{(column) => (
|
||||
<TableColumn
|
||||
key={column.id}
|
||||
align={column.id === "actions" ? "center" : "start"}
|
||||
>
|
||||
{column.name}
|
||||
</TableColumn>
|
||||
)}
|
||||
</TableHeader>
|
||||
<TableBody items={state.pilots}>
|
||||
{(item) => (
|
||||
<TableRow key={item.id}>
|
||||
{(columnKey => (
|
||||
<TableCell>
|
||||
{renderCell(item, columnKey)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
}
|
||||
{state.pilots.length > 0 && screenSize === ScreenSize.SM &&
|
||||
<PilotCard pilots={state.pilots} onDelete={onDeletePilot} onOpenCloseForm={onOpenClosePilotForm} />
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
{state.isFormOpen && (
|
||||
<PilotForm
|
||||
isDrawerOpen={state.isFormOpen}
|
||||
mode={state.formMode}
|
||||
onOpenClose={(mode) => onOpenClosePilotForm(mode)}
|
||||
pilotId={state.selectedPilotId}
|
||||
/>
|
||||
)}
|
||||
{state.isConfirmDialogOpen && (
|
||||
<ConfirmationDialog
|
||||
contentText="Are you sure you want to delete the pilot entry? Deleting a pilot will delete the pilot and delete all of the pilot's logbook entries."
|
||||
isLoading={state.isConfirmDialogLoading}
|
||||
isOpen={state.isConfirmDialogOpen}
|
||||
onCancel={onConfirmationDialogCancel}
|
||||
onConfirm={onConfirmationDialogConfirm}
|
||||
title="Confirm Delete"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default Pilots;
|
||||
|
||||
14
client/src/components/pilots/PilotsState.interface.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { FormMode } from "../../enums/formMode";
|
||||
import { Alert } from "../../interfaces/Alert.interface";
|
||||
import { Pilot } from "./Pilot.interface";
|
||||
|
||||
export interface PilotsState {
|
||||
alert: Alert | undefined;
|
||||
isConfirmDialogLoading: boolean;
|
||||
isConfirmDialogOpen: boolean;
|
||||
isFormOpen: boolean;
|
||||
isLoading: boolean;
|
||||
formMode: FormMode;
|
||||
pilots: Pilot[];
|
||||
selectedPilotId: string | undefined;
|
||||
}
|
||||
93
client/src/components/pilots/reducer.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { FormMode } from "../../enums/formMode";
|
||||
import { Alert } from "../../interfaces/Alert.interface";
|
||||
import { Pilot } from "./Pilot.interface";
|
||||
import { PilotsState } from './PilotsState.interface'
|
||||
|
||||
type Action =
|
||||
| {
|
||||
type: 'SET_DELETE';
|
||||
payload: {
|
||||
isConfirmDialogOpen: boolean;
|
||||
selectedPilotId: string | undefined;
|
||||
}
|
||||
}
|
||||
| { type: 'SET_PILOTS'; payload: Pilot[] }
|
||||
| { type: 'SET_ALERT'; payload: Alert | undefined }
|
||||
| { type: 'SET_FORM_MODE'; payload: FormMode }
|
||||
| { type: 'SET_IS_CONFIRMATION_DIALOG_LOADING'; payload: boolean }
|
||||
| { type: 'SET_IS_LOADING'; payload: boolean }
|
||||
| {
|
||||
type: 'SET_OPEN_CLOSE_ENTRY_FORM';
|
||||
payload: {
|
||||
formMode: FormMode;
|
||||
selectedPilotId: string | undefined;
|
||||
isFormOpen: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export const initialState: PilotsState = {
|
||||
alert: undefined,
|
||||
formMode: FormMode.CANCEL,
|
||||
isConfirmDialogLoading: false,
|
||||
isConfirmDialogOpen: false,
|
||||
isFormOpen: false,
|
||||
isLoading: false,
|
||||
pilots: [],
|
||||
selectedPilotId: undefined
|
||||
}
|
||||
|
||||
export const reducer = (
|
||||
state: PilotsState,
|
||||
action: Action
|
||||
): PilotsState => {
|
||||
switch (action.type) {
|
||||
case 'SET_DELETE': {
|
||||
return {
|
||||
...state,
|
||||
isConfirmDialogOpen: action.payload.isConfirmDialogOpen,
|
||||
selectedPilotId: action.payload.selectedPilotId
|
||||
}
|
||||
}
|
||||
case 'SET_PILOTS': {
|
||||
return {
|
||||
...state,
|
||||
pilots: action.payload
|
||||
}
|
||||
}
|
||||
case 'SET_ALERT': {
|
||||
return {
|
||||
...state,
|
||||
alert: action.payload
|
||||
}
|
||||
}
|
||||
case 'SET_FORM_MODE': {
|
||||
return {
|
||||
...state,
|
||||
formMode: action.payload
|
||||
}
|
||||
}
|
||||
case 'SET_IS_CONFIRMATION_DIALOG_LOADING': {
|
||||
return {
|
||||
...state,
|
||||
isConfirmDialogLoading: action.payload
|
||||
}
|
||||
}
|
||||
case 'SET_IS_LOADING': {
|
||||
return {
|
||||
...state,
|
||||
isLoading: action.payload
|
||||
}
|
||||
}
|
||||
case 'SET_OPEN_CLOSE_ENTRY_FORM': {
|
||||
return {
|
||||
...state,
|
||||
formMode: action.payload.formMode,
|
||||
isFormOpen: action.payload.isFormOpen,
|
||||
selectedPilotId: action.payload.selectedPilotId
|
||||
}
|
||||
}
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
}
|
||||
7
client/src/components/siteNav/ISiteNavProps.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { InteractionStatus } from '@azure/msal-browser';
|
||||
|
||||
export interface ISiteNavProps {
|
||||
handleSignIn: () => void;
|
||||
handleSignOut: () => void;
|
||||
inProgress: InteractionStatus;
|
||||
}
|
||||
132
client/src/components/siteNav/SiteNav.tsx
Normal file
@@ -0,0 +1,132 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useAppContext } from '../../hooks/appContext/UseAppContext';
|
||||
import { AxiosResponse } from 'axios';
|
||||
import { User } from '@microsoft/microsoft-graph-types';
|
||||
import { useOidc } from '../../auth/oidcConfig';
|
||||
import { Avatar, Button, Link, Navbar, NavbarBrand, NavbarContent, NavbarItem, DropdownTrigger, DropdownMenu, DropdownItem, Dropdown } from '@heroui/react';
|
||||
import httpClient from '../../httpClient/httpClient'
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faPlane, faSignIn, faSignOut } from '@fortawesome/free-solid-svg-icons'
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
const SiteNav = () => {
|
||||
const [userPhoto, setUserPhoto] = useState<string>();
|
||||
const appContext = useAppContext();
|
||||
const { isUserLoggedIn, logout, login } = useOidc()
|
||||
const { pathname } = useLocation()
|
||||
const pages = [
|
||||
{
|
||||
name: 'Flights',
|
||||
path: '/'
|
||||
},
|
||||
{
|
||||
name: 'Logbook',
|
||||
path: '/logbook'
|
||||
},
|
||||
{
|
||||
name: 'Pilots',
|
||||
path: '/pilots'
|
||||
}
|
||||
];
|
||||
const getUserProfile = async (): Promise<User> => {
|
||||
try {
|
||||
const response: AxiosResponse = await httpClient.get(`api/msgraph/profile`);
|
||||
const userProfile: User = response.data;
|
||||
|
||||
return userProfile;
|
||||
} catch (error) {
|
||||
throw new Error();
|
||||
}
|
||||
};
|
||||
const getUserPhoto = async (): Promise<string> => {
|
||||
try {
|
||||
const response: AxiosResponse = await httpClient.get(`api/msgraph/photo`, {
|
||||
responseType: 'arraybuffer'
|
||||
});
|
||||
const arrayBufferView = new Uint8Array(response.data);
|
||||
const blob = new Blob([arrayBufferView], { type: 'image/png' });
|
||||
const imageUrl = window.URL.createObjectURL(blob);
|
||||
|
||||
return imageUrl;
|
||||
} catch (error) {
|
||||
throw new Error();
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const setUserProfile = async () => {
|
||||
try {
|
||||
const userProfile = await getUserProfile();
|
||||
const userPhoto = await getUserPhoto();
|
||||
|
||||
setUserPhoto(userPhoto);
|
||||
|
||||
appContext.dispatch({
|
||||
type: 'SET_USER_PROFILE',
|
||||
payload: userProfile
|
||||
});
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
};
|
||||
|
||||
if (
|
||||
isUserLoggedIn &&
|
||||
Object.keys(appContext.state.userProfile).length === 0
|
||||
) {
|
||||
setUserProfile();
|
||||
}
|
||||
}, [isUserLoggedIn]);
|
||||
|
||||
return (
|
||||
<Navbar isBordered maxWidth='full' position='static'>
|
||||
<NavbarContent>
|
||||
<NavbarBrand>
|
||||
<img
|
||||
height={35}
|
||||
width={35}
|
||||
src='noahspan-logo.png'
|
||||
style={{ marginRight: '5px' }}
|
||||
/>
|
||||
<FontAwesomeIcon icon={faPlane} size='2x' />
|
||||
</NavbarBrand>
|
||||
</NavbarContent>
|
||||
<NavbarContent justify='center'>
|
||||
{pages.length > 0 && pages.map((page, index) => {
|
||||
return (
|
||||
<NavbarItem isActive={pathname === page.path ? true : false} key={index}>
|
||||
<Link color={pathname === page.path ? 'primary' : 'foreground'} href={page.path}>
|
||||
{page.name}
|
||||
</Link>
|
||||
</NavbarItem>
|
||||
)
|
||||
})}
|
||||
</NavbarContent>
|
||||
<NavbarContent justify='end'>
|
||||
{!isUserLoggedIn &&
|
||||
<Button
|
||||
color='default'
|
||||
onPress={() => login()}
|
||||
startContent={<FontAwesomeIcon icon={faSignIn} />}
|
||||
>
|
||||
Sign In
|
||||
</Button>
|
||||
}
|
||||
{isUserLoggedIn &&
|
||||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<Avatar name={appContext.state.userProfile.displayName?.toString()} src={userPhoto}></Avatar>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu>
|
||||
<DropdownItem key='signout' onPress={() => logout({redirectTo: 'specific url', url: '/'})} startContent={<FontAwesomeIcon icon={faSignOut} />}>
|
||||
Sign Out
|
||||
</DropdownItem>
|
||||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
}
|
||||
</NavbarContent>
|
||||
</Navbar>
|
||||
);
|
||||
};
|
||||
|
||||
export default SiteNav;
|
||||
0
client/src/components/stateSelect/StateSelect.tsx
Normal file
53
client/src/components/trackMap/TrackMap.css
Normal file
@@ -0,0 +1,53 @@
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
html,
|
||||
body {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #eee;
|
||||
font-family: Helvetica Neue, Helvetica, Arial, sans-serif;
|
||||
font-size: 14px;
|
||||
color: #000;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.swiper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.swiper-wrapper {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.swiper-slide {
|
||||
text-align: center;
|
||||
font-size: 18px;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.swiper-slide img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.swiper-pagination-bullet {
|
||||
background-color: #000000;
|
||||
height: 13px;
|
||||
width: 13px;
|
||||
border: 2px solid #FFFFFF;
|
||||
}
|
||||
|
||||
.swiper-pagination-bullet-active {
|
||||
box-shadow: 0 0 0 1px #000000;
|
||||
}
|
||||
59
client/src/components/trackMap/TrackMap.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
import { Suspense, useEffect, useState } from 'react';
|
||||
import { TrackMapProps } from './TrackMapProps.interface';
|
||||
import { AxiosInstance, AxiosResponse } from 'axios';
|
||||
import { useAuth } from 'react-oidc-context'
|
||||
import { MapContainer, TileLayer } from 'react-leaflet';
|
||||
import ReactLeafletKml from 'react-leaflet-kml';
|
||||
import 'swiper/css';
|
||||
import 'swiper/css/pagination';
|
||||
import 'swiper/css';
|
||||
import './TrackMap.css';
|
||||
import 'leaflet/dist/leaflet.css';
|
||||
import httpClient from '../../httpClient/httpClient'
|
||||
|
||||
const TrackMap = ({ height, logId, tracks }: TrackMapProps) => {
|
||||
const [kmls, setKmls] = useState<any[]>([])
|
||||
const auth = useAuth();
|
||||
|
||||
useEffect(() => {
|
||||
const getTracks = async () => {
|
||||
const convertedTracks: any[] = []
|
||||
|
||||
for (const track of tracks) {
|
||||
const trackUrlSplit = track.url.split('/')
|
||||
const filename = trackUrlSplit[trackUrlSplit.length - 1];
|
||||
const response: AxiosResponse = await httpClient.get(
|
||||
`api/tracks/${logId}/${filename}`
|
||||
);
|
||||
const kml = new DOMParser().parseFromString(response.data, 'text/xml')
|
||||
|
||||
convertedTracks.push(kml);
|
||||
}
|
||||
|
||||
setKmls(convertedTracks)
|
||||
}
|
||||
|
||||
getTracks();
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<MapContainer
|
||||
center={[45.14489, -93.21019]}
|
||||
scrollWheelZoom={false}
|
||||
style={{ height: height, width: '100%' }}
|
||||
zoom={8}
|
||||
>
|
||||
<Suspense>
|
||||
<TileLayer
|
||||
attribution='© <a href="http://osm.org/copyright">OpenStreetMap</a> contributors'
|
||||
url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
|
||||
/>
|
||||
{kmls.length > 0 && kmls.map((kml) => (
|
||||
<ReactLeafletKml kml={kml} />
|
||||
))}
|
||||
</Suspense>
|
||||
</MapContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default TrackMap;
|
||||
@@ -0,0 +1,5 @@
|
||||
export interface TrackMapProps {
|
||||
height: string;
|
||||
logId: string;
|
||||
tracks: {id: string; order: number; url: string}[];
|
||||
}
|
||||
241
client/src/components/tracksForm/TracksForm.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import { useEffect, useReducer, useRef } from "react";
|
||||
import { AxiosError, AxiosResponse } from "axios";
|
||||
import { TracksFormProps } from "./TracksFormProps.interface";
|
||||
import { FormMode } from "../../enums/formMode";
|
||||
import { LogbookEntry } from "../logbook/LogbookEntry.interface";
|
||||
import ConfirmationDialog from "../confirmationDialog/ConfirmationDialog";
|
||||
import { initialState, reducer } from "./reducer";
|
||||
import TrackMap from "../trackMap/TrackMap";
|
||||
import httpClient from "../../httpClient/httpClient";
|
||||
import { Button, Input, Spinner } from '@heroui/react';
|
||||
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||
import { faUpload, faTrash, faXmark } from '@fortawesome/free-solid-svg-icons';
|
||||
import { useLogbookContext } from "../../hooks/logbookContext/UseLogbookContext";
|
||||
|
||||
const TracksForm = () => {
|
||||
const [state, dispatch] = useReducer(reducer, initialState)
|
||||
const logbookContext = useLogbookContext();
|
||||
|
||||
const getTracks = async () => {
|
||||
try {
|
||||
const response: AxiosResponse = await httpClient.get(
|
||||
`api/tracks/${logbookContext.state.selectedLogId}`
|
||||
)
|
||||
|
||||
const tracks = response.data;
|
||||
|
||||
dispatch({ type: 'SET_TRACKS', payload: tracks })
|
||||
} catch (error) {
|
||||
const axiosError = error as AxiosError;
|
||||
|
||||
logbookContext.dispatch({ type: 'SET_FORM_ALERT', payload: { severity: 'danger', message: axiosError.message }})
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileUpload = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
try {
|
||||
dispatch({ type: 'SET_IS_LOADING', payload: true})
|
||||
|
||||
const file = event.target.files![0]
|
||||
const formData = new FormData();
|
||||
const order = state.tracks.length + 1
|
||||
|
||||
formData.append('file', file);
|
||||
|
||||
httpClient.interceptors.request.use((config) => {
|
||||
config.headers["Content-Type"] = 'multipart/form-data'
|
||||
|
||||
return config
|
||||
});
|
||||
|
||||
await httpClient.post(`api/tracks/${logbookContext.state.selectedLogId}/${order}`, formData);
|
||||
await getTracks();
|
||||
// const uploadUrl = uploadResponse.data.url;
|
||||
// const tracks: string[] = log.tracks ? JSON.parse(log.tracks!) : [];
|
||||
|
||||
// tracks.push(uploadUrl)
|
||||
// log.tracks = JSON.stringify(tracks);
|
||||
// await httpClient.put(`api/logs/log/${logbookContext.state.selectedLogId}`, log, config);
|
||||
|
||||
// const updatedLog = await getLog();
|
||||
|
||||
// dispatch({ type: 'SET_TRACKS', payload: JSON.parse(updatedLog.tracks!) })
|
||||
} catch (error) {
|
||||
console.log(error)
|
||||
} finally {
|
||||
dispatch({ type: 'SET_IS_LOADING', payload: false });
|
||||
}
|
||||
}
|
||||
|
||||
const onDeleteTrack = async (id: string, filename: string, index: number) => {
|
||||
dispatch({ type: 'SET_ON_DELETE_TRACK', payload: { isConfirmDialogOpen: true, selectedTrack: { id: id, filename: filename, index: index }}})
|
||||
}
|
||||
|
||||
const onConfirmDialogConfirm = async () => {
|
||||
try {
|
||||
await httpClient.delete(`api/tracks/${state.selectedTrack!.id}/${state.selectedTrack!.filename}/${logbookContext.state.selectedLogId}`);
|
||||
|
||||
await getTracks();
|
||||
|
||||
// tracks.splice(state.selectedTrack!.index, 1);
|
||||
// log.tracks = JSON.stringify(tracks);
|
||||
// await httpClient.put(`api/logs/log/${selectedRowKey}`, log, config);
|
||||
|
||||
// const updatedLog = await getLog();
|
||||
|
||||
// dispatch({ type: 'SET_TRACKS', payload: JSON.parse(updatedLog.tracks!) })
|
||||
dispatch({ type: 'SET_IS_CONFIRM_DIALOG_OPEN', payload: false })
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
}
|
||||
}
|
||||
|
||||
const onConfirmDialogCancel = async () => {
|
||||
dispatch({ type: 'SET_IS_CONFIRM_DIALOG_OPEN', payload: false })
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (logbookContext.state.selectedLogId) {
|
||||
getTracks();
|
||||
}
|
||||
}, [logbookContext.state.selectedLogId])
|
||||
|
||||
useEffect(() => {
|
||||
console.log(logbookContext.state.formMode)
|
||||
if (logbookContext.state.formMode === FormMode.VIEW) {
|
||||
dispatch({ type: 'SET_IS_DISABLED', payload: true });
|
||||
}
|
||||
}, [logbookContext.state.formMode]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log(state.isDisabled)
|
||||
}, [state.isDisabled])
|
||||
|
||||
return (
|
||||
<div className='grid grid-cols-12 gap-3'>
|
||||
{state.tracks.length > 0 &&
|
||||
<div className="col-span-12">
|
||||
<TrackMap height='400px' logId={logbookContext.state.selectedLogId!} tracks={state.tracks} />
|
||||
</div>
|
||||
}
|
||||
<>
|
||||
{state.tracks.length > 0 && state.tracks.map((track, index) => {
|
||||
const filename: string = track.url.substring(track.url.lastIndexOf('/') + 1);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className='col-span-10'>
|
||||
<Input isDisabled={state.isDisabled} key={index} type='text' value={filename}/>
|
||||
</div>
|
||||
<div className='col-span-2'>
|
||||
<Button isDisabled={state.isDisabled} key={index} isIconOnly onPress={() => onDeleteTrack(track.id, filename, index)}><FontAwesomeIcon icon={faTrash} /></Button>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
})}
|
||||
<div className='col-span-12'>
|
||||
<Button
|
||||
as='label'
|
||||
color='primary'
|
||||
isDisabled={state.isDisabled}
|
||||
fullWidth={true}
|
||||
startContent={<FontAwesomeIcon icon={faUpload} />}
|
||||
>
|
||||
Upload Track
|
||||
<input hidden onChange={handleFileUpload} type='file' />
|
||||
</Button>
|
||||
</div>
|
||||
{state.isConfirmDialogOpen && (
|
||||
<ConfirmationDialog
|
||||
contentText="Are you sure you want to delete this track?"
|
||||
isLoading={state.isConfirmDialogLoading}
|
||||
isOpen={state.isConfirmDialogOpen}
|
||||
onCancel={onConfirmDialogCancel}
|
||||
onConfirm={onConfirmDialogConfirm}
|
||||
title="Confirm Delete"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// const getConfig = async () => {
|
||||
// const config = auth.isAuthenticated
|
||||
// ? { headers: { Authorization: auth.user?.access_token } }
|
||||
// : {};
|
||||
|
||||
// return config
|
||||
// }
|
||||
|
||||
// const getLog = async (): Promise<ILogbookEntry> => {
|
||||
// const logResponse: AxiosResponse = await httpClient.get(
|
||||
// `api/tracks/${selectedLogId}`,
|
||||
// await getConfig()
|
||||
// );
|
||||
// const logData: ILogbookEntry = logResponse.data;
|
||||
|
||||
// return logData
|
||||
// }
|
||||
|
||||
|
||||
|
||||
// const onCancel = () => {
|
||||
// onOpenClose(FormMode.CANCEL)
|
||||
// }
|
||||
|
||||
// const onDeleteTrack = async (fileName: string, index: number) => {
|
||||
// dispatch({ type: 'SET_ON_DELETE_TRACK', payload: { isConfirmDialogOpen: true, selectedTrack: { fileName: fileName, index: index }}})
|
||||
// }
|
||||
|
||||
|
||||
|
||||
// useEffect(() => {
|
||||
// const updateTracks = async () => {
|
||||
// const log = await getLog();
|
||||
|
||||
// dispatch({ type: 'SET_TRACKS', payload: log.tracks! });
|
||||
// }
|
||||
|
||||
// updateTracks();
|
||||
// }, [])
|
||||
|
||||
// return (
|
||||
// <div className='grid grid-cols-12 gap-3'>
|
||||
// {}
|
||||
// {logbookContext.state.formMode === FormMode.EDIT &&
|
||||
// <>
|
||||
// {state.isLoading &&
|
||||
// <>
|
||||
// <div>
|
||||
// <Spinner size='lg' />
|
||||
// </div>
|
||||
// <div>
|
||||
// Loading...
|
||||
// </div>
|
||||
// </>
|
||||
// }
|
||||
// {!state.isLoading && state.tracks.length > 0 && state.tracks.map((track, index) => {
|
||||
// const trackSplit = track.url.split('/')
|
||||
// const filename = trackSplit[trackSplit.length - 1];
|
||||
|
||||
// return (
|
||||
// <>
|
||||
// <div>
|
||||
// <Input disabled={true} value={filename} />
|
||||
// </div>
|
||||
// <div>
|
||||
// <Button isIconOnly onPress={() => onDeleteTrack(filename, index)}><FontAwesomeIcon icon={faTrash} /></Button>
|
||||
// </div>
|
||||
// </>
|
||||
// )
|
||||
// })}
|
||||
// </>
|
||||
// }
|
||||
|
||||
|
||||
// </div>
|
||||
// )
|
||||
// }
|
||||
|
||||
export default TracksForm;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { FormMode } from "../../enums/formMode";
|
||||
|
||||
export interface TracksFormProps {
|
||||
isDrawerOpen: boolean;
|
||||
mode: FormMode;
|
||||
onOpenClose: (mode: FormMode) => void;
|
||||
selectedLogId: string | undefined;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export interface TracksFormState {
|
||||
isConfirmDialogOpen: boolean;
|
||||
isConfirmDialogLoading: boolean;
|
||||
isDisabled: boolean;
|
||||
isLoading: boolean;
|
||||
selectedTrack: {
|
||||
id: string,
|
||||
filename: string,
|
||||
index: number
|
||||
} | undefined;
|
||||
tracks: { id: string; order: number; url: string; }[];
|
||||
}
|
||||
63
client/src/components/tracksForm/reducer.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { TracksFormState } from "./TracksFormState.interface";
|
||||
|
||||
type Action =
|
||||
| { type: 'SET_IS_CONFIRM_DIALOG_OPEN'; payload: boolean }
|
||||
| { type: 'SET_IS_CONFORM_DIALOG_LOADING'; payload: boolean }
|
||||
| { type: 'SET_IS_DISABLED'; payload: boolean }
|
||||
| { type: 'SET_IS_LOADING'; payload: boolean }
|
||||
| { type: 'SET_ON_DELETE_TRACK'; payload: { isConfirmDialogOpen: boolean, selectedTrack: { id: string, filename: string, index: number } }}
|
||||
| { type: 'SET_TRACKS'; payload: { id: string; order: number; url: string }[] };
|
||||
|
||||
export const initialState: TracksFormState = {
|
||||
isConfirmDialogOpen: false,
|
||||
isConfirmDialogLoading: false,
|
||||
isDisabled: false,
|
||||
isLoading: false,
|
||||
selectedTrack: undefined,
|
||||
tracks: []
|
||||
}
|
||||
|
||||
export const reducer = (state: TracksFormState, action: Action): TracksFormState => {
|
||||
switch (action.type) {
|
||||
case 'SET_IS_CONFIRM_DIALOG_OPEN': {
|
||||
return {
|
||||
...state,
|
||||
isConfirmDialogOpen: action.payload
|
||||
}
|
||||
}
|
||||
case 'SET_IS_CONFORM_DIALOG_LOADING': {
|
||||
return {
|
||||
...state,
|
||||
isConfirmDialogLoading: action.payload
|
||||
}
|
||||
}
|
||||
case 'SET_IS_DISABLED': {
|
||||
return {
|
||||
...state,
|
||||
isDisabled: action.payload
|
||||
}
|
||||
}
|
||||
case 'SET_IS_LOADING': {
|
||||
return {
|
||||
...state,
|
||||
isLoading: action.payload
|
||||
}
|
||||
}
|
||||
case 'SET_ON_DELETE_TRACK': {
|
||||
return {
|
||||
...state,
|
||||
isConfirmDialogOpen: action.payload.isConfirmDialogOpen,
|
||||
selectedTrack: action.payload.selectedTrack
|
||||
}
|
||||
}
|
||||
case 'SET_TRACKS': {
|
||||
return {
|
||||
...state,
|
||||
tracks: action.payload
|
||||
}
|
||||
}
|
||||
default: {
|
||||
return state
|
||||
}
|
||||
}
|
||||
}
|
||||
5
client/src/context/appContext/AppContext.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Context, createContext } from 'react';
|
||||
import { IAppContextProps } from './IAppContextProps';
|
||||
|
||||
export const AppContext: Context<IAppContextProps> =
|
||||
createContext<IAppContextProps>({} as IAppContextProps);
|
||||
30
client/src/context/appContext/AppContextProvider.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useMemo, useReducer } from 'react';
|
||||
import { AppContext } from './AppContext';
|
||||
import { IAppContextProviderProps } from './IAppContextProviderProps';
|
||||
import { IAppContextProps } from './IAppContextProps';
|
||||
import { IAppContextState } from './IAppContextState';
|
||||
import { reducer } from './reducer';
|
||||
|
||||
const AppContextProvider: React.FC<IAppContextProviderProps> = (
|
||||
props: IAppContextProviderProps
|
||||
) => {
|
||||
const intialState: IAppContextState = {
|
||||
featureFlags: [],
|
||||
userProfile: {}
|
||||
};
|
||||
const [state, dispatch] = useReducer(reducer, intialState);
|
||||
const contextValue: IAppContextProps = useMemo(() => {
|
||||
return {
|
||||
state,
|
||||
dispatch
|
||||
};
|
||||
}, [state, dispatch]);
|
||||
|
||||
return (
|
||||
<AppContext.Provider value={contextValue}>
|
||||
{props.children}
|
||||
</AppContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppContextProvider;
|
||||
7
client/src/context/appContext/IAppContextProps.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Action } from './reducer';
|
||||
import { IAppContextState } from './IAppContextState';
|
||||
|
||||
export interface IAppContextProps {
|
||||
state: IAppContextState;
|
||||
dispatch: React.Dispatch<Action>;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface IAppContextProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
6
client/src/context/appContext/IAppContextState.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { User } from '@microsoft/microsoft-graph-types';
|
||||
|
||||
export interface IAppContextState {
|
||||
featureFlags: { key: string; enabled: boolean }[];
|
||||
userProfile: User;
|
||||
}
|
||||
29
client/src/context/appContext/reducer.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { User } from '@microsoft/microsoft-graph-types';
|
||||
import { IAppContextState } from './IAppContextState';
|
||||
|
||||
export type Action =
|
||||
| { type: 'SET_FEATURE_FLAGS'; payload: { key: string; enabled: boolean }[] }
|
||||
| { type: 'SET_USER_PROFILE'; payload: User };
|
||||
|
||||
export const reducer = (
|
||||
state: IAppContextState,
|
||||
action: Action
|
||||
): IAppContextState => {
|
||||
switch (action.type) {
|
||||
case 'SET_FEATURE_FLAGS': {
|
||||
return {
|
||||
...state,
|
||||
featureFlags: action.payload
|
||||
};
|
||||
}
|
||||
case 'SET_USER_PROFILE': {
|
||||
return {
|
||||
...state,
|
||||
userProfile: action.payload
|
||||
};
|
||||
}
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
5
client/src/context/logbookContext/LogbookContext.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Context, createContext } from 'react';
|
||||
import { LogbookContextProps } from './LogbookContextProps.interface';
|
||||
|
||||
export const LogbookContext: Context<LogbookContextProps> =
|
||||
createContext<LogbookContextProps>({} as LogbookContextProps);
|
||||
@@ -0,0 +1,7 @@
|
||||
import { Action } from './reducer';
|
||||
import { LogbookContextState } from './LogbookContextState.interface';
|
||||
|
||||
export interface LogbookContextProps {
|
||||
state: LogbookContextState;
|
||||
dispatch: React.Dispatch<Action>;
|
||||
}
|
||||
35
client/src/context/logbookContext/LogbookContextProvider.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { useMemo, useReducer } from 'react';
|
||||
import { LogbookContext } from './LogbookContext';
|
||||
import { LogbookContextProviderProps } from './LogbookContextProviderProps.interface';
|
||||
import { LogbookContextProps } from './LogbookContextProps.interface';
|
||||
import { LogbookContextState } from './LogbookContextState.interface';
|
||||
import { reducer } from './reducer';
|
||||
import { FormMode } from '../../enums/formMode';
|
||||
|
||||
const AppContextProvider: React.FC<LogbookContextProviderProps> = (
|
||||
props: LogbookContextProviderProps
|
||||
) => {
|
||||
const initialState: LogbookContextState = {
|
||||
formAlert: undefined,
|
||||
formMode: FormMode.VIEW,
|
||||
isDrawerOpen: false,
|
||||
isFormDisabled: false,
|
||||
isFormLoading: false,
|
||||
selectedLogId: ''
|
||||
}
|
||||
const [state, dispatch] = useReducer(reducer, initialState);
|
||||
const contextValue: LogbookContextProps = useMemo(() => {
|
||||
return {
|
||||
state,
|
||||
dispatch
|
||||
};
|
||||
}, [state, dispatch]);
|
||||
|
||||
return (
|
||||
<LogbookContext.Provider value={contextValue}>
|
||||
{props.children}
|
||||
</LogbookContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default AppContextProvider;
|
||||
@@ -0,0 +1,3 @@
|
||||
export interface LogbookContextProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { FormMode } from "../../enums/formMode";
|
||||
import { Alert } from "../../interfaces/Alert.interface";
|
||||
|
||||
export interface LogbookContextState {
|
||||
formAlert: Alert | undefined ;
|
||||
formMode: FormMode;
|
||||
isDrawerOpen: boolean;
|
||||
isFormDisabled: boolean;
|
||||
isFormLoading: boolean;
|
||||
selectedLogId: string | undefined;
|
||||
}
|
||||
67
client/src/context/logbookContext/reducer.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { FormMode } from '../../enums/formMode';
|
||||
import { Alert } from '../../interfaces/Alert.interface';
|
||||
import { LogbookContextState } from './LogbookContextState.interface';
|
||||
|
||||
export type Action =
|
||||
| { type: 'SET_FORM_ALERT'; payload: Alert | undefined }
|
||||
| { type: 'SET_FORM_MODE'; payload: FormMode }
|
||||
| { type: 'SET_IS_DRAWER_OPEN'; payload: boolean }
|
||||
| { type: 'SET_IS_FORM_DISABLED'; payload: boolean }
|
||||
| { type: 'SET_IS_FORM_LOADING'; payload: boolean }
|
||||
| { type: 'SET_OPEN_CLOSE_DRAWER'; payload: { formMode: FormMode, isDrawerOpen: boolean, selectedLogId: string | undefined }}
|
||||
| { type: 'SET_SELECTED_LOG_ID'; payload: string }
|
||||
|
||||
export const reducer = (
|
||||
state: LogbookContextState,
|
||||
action: Action
|
||||
): LogbookContextState => {
|
||||
switch (action.type) {
|
||||
case 'SET_FORM_ALERT': {
|
||||
return {
|
||||
...state,
|
||||
formAlert: action.payload
|
||||
};
|
||||
}
|
||||
case 'SET_FORM_MODE': {
|
||||
return {
|
||||
...state,
|
||||
formMode: action.payload
|
||||
}
|
||||
}
|
||||
case 'SET_IS_DRAWER_OPEN': {
|
||||
return {
|
||||
...state,
|
||||
isDrawerOpen: action.payload
|
||||
};
|
||||
}
|
||||
case 'SET_IS_FORM_DISABLED': {
|
||||
return {
|
||||
...state,
|
||||
isFormDisabled: action.payload
|
||||
}
|
||||
}
|
||||
case 'SET_IS_FORM_LOADING': {
|
||||
return {
|
||||
...state,
|
||||
isFormLoading: action.payload
|
||||
}
|
||||
}
|
||||
case 'SET_OPEN_CLOSE_DRAWER': {
|
||||
return {
|
||||
...state,
|
||||
formMode: action.payload.formMode,
|
||||
isDrawerOpen: action.payload.isDrawerOpen,
|
||||
selectedLogId: action.payload.selectedLogId
|
||||
}
|
||||
}
|
||||
case 'SET_SELECTED_LOG_ID': {
|
||||
return {
|
||||
...state,
|
||||
selectedLogId: action.payload
|
||||
}
|
||||
}
|
||||
default: {
|
||||
return state;
|
||||
}
|
||||
}
|
||||
};
|
||||
6
client/src/enums/formMode.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export enum FormMode {
|
||||
ADD = 'ADD',
|
||||
EDIT = 'EDIT',
|
||||
VIEW = 'VIEW',
|
||||
CANCEL = 'CANCEL'
|
||||
}
|
||||
7
client/src/enums/screenSize.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export enum ScreenSize {
|
||||
SM,
|
||||
MD,
|
||||
LG,
|
||||
XL,
|
||||
XXL
|
||||
}
|
||||
4
client/src/enums/userRole.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum UserRole {
|
||||
READ = 'Flying.Read',
|
||||
WRITE = 'Flying.Write'
|
||||
}
|
||||
3
client/src/hero.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// hero.ts
|
||||
import { heroui } from "@heroui/react";
|
||||
export default heroui();
|
||||
32
client/src/hooks/accessToken/UseAcessToken.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
// import {
|
||||
// AuthenticationResult,
|
||||
// InteractionRequiredAuthError
|
||||
// } from '@azure/msal-browser';
|
||||
// import { useMsal } from '@azure/msal-react';
|
||||
|
||||
// export const useAccessToken = () => {
|
||||
// const { accounts, instance } = useMsal();
|
||||
// const getAccessToken = async () => {
|
||||
// const tokenRequest = {
|
||||
// account: accounts[0],
|
||||
// scopes: [`api://${import.meta.env.VITE_CLIENT_ID}/user_impersonation`]
|
||||
// };
|
||||
|
||||
// try {
|
||||
// const response: AuthenticationResult =
|
||||
// await instance.acquireTokenSilent(tokenRequest);
|
||||
|
||||
// return `Bearer ${response.accessToken}`;
|
||||
// } catch (error) {
|
||||
// if (error instanceof InteractionRequiredAuthError) {
|
||||
// await instance.acquireTokenRedirect(tokenRequest);
|
||||
// }
|
||||
|
||||
// throw error;
|
||||
// }
|
||||
// };
|
||||
|
||||
// return {
|
||||
// getAccessToken
|
||||
// };
|
||||
// };
|
||||
11
client/src/hooks/appContext/UseAppContext.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useContext } from 'react';
|
||||
import { AppContext } from '../../context/appContext/AppContext';
|
||||
|
||||
export const useAppContext = () => {
|
||||
const { state, dispatch } = useContext(AppContext);
|
||||
|
||||
return {
|
||||
state,
|
||||
dispatch
|
||||
};
|
||||
};
|
||||
11
client/src/hooks/logbookContext/UseLogbookContext.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { useContext } from 'react';
|
||||
import { LogbookContext } from '../../context/logbookContext/LogbookContext';
|
||||
|
||||
export const useLogbookContext = () => {
|
||||
const { state, dispatch } = useContext(LogbookContext);
|
||||
|
||||
return {
|
||||
state,
|
||||
dispatch
|
||||
};
|
||||
};
|
||||
38
client/src/hooks/logs/UseLogs.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from 'react-oidc-context';
|
||||
import { AxiosInstance, AxiosResponse } from "axios";
|
||||
import { LogbookEntry } from "../../components/logbook/LogbookEntry.interface";
|
||||
import httpClient from '../../httpClient/httpClient'
|
||||
|
||||
export const useLogs = () => {
|
||||
const [logs, setLogs] = useState<LogbookEntry[]>();
|
||||
const [logsLoading, setLogsLoading] = useState<boolean>(false);
|
||||
const auth = useAuth()
|
||||
|
||||
useEffect(() => {
|
||||
const getLogs = async () => {
|
||||
try {
|
||||
setLogsLoading(true);
|
||||
|
||||
const response: AxiosResponse = await httpClient.get(
|
||||
`api/logs`
|
||||
);
|
||||
const logs: LogbookEntry[] = response.data;
|
||||
|
||||
logs.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
||||
setLogs(logs)
|
||||
} catch (error) {
|
||||
return error;
|
||||
} finally {
|
||||
setLogsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
getLogs();
|
||||
}, [])
|
||||
|
||||
return {
|
||||
logs,
|
||||
logsLoading
|
||||
}
|
||||
}
|
||||
40
client/src/hooks/pilots/UsePilots.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AxiosInstance, AxiosResponse } from 'axios';
|
||||
import httpClient from '../../httpClient/httpClient';
|
||||
|
||||
export const usePilots = () => {
|
||||
const [pilots, setPilots] = useState<any[]>();
|
||||
|
||||
const getPilot = async (pilotId: string) => {
|
||||
try {
|
||||
const response: AxiosResponse = await httpClient.get(
|
||||
`api/pilots/${pilotId}`
|
||||
);
|
||||
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const getPilots = async () => {
|
||||
try {
|
||||
const response: AxiosResponse = await httpClient.get(
|
||||
`/api/pilots`
|
||||
);
|
||||
|
||||
setPilots(response.data);
|
||||
} catch (error) {
|
||||
return error;
|
||||
}
|
||||
};
|
||||
|
||||
getPilots();
|
||||
}, []);
|
||||
|
||||
return {
|
||||
getPilot,
|
||||
pilots
|
||||
};
|
||||
};
|
||||
66
client/src/hooks/useBreakpoints/UseBreakpoints.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ScreenSize } from '../../enums/screenSize';
|
||||
|
||||
export const useBreakpoints = () => {
|
||||
const [screenSize, setScreenSize] = useState<ScreenSize>();
|
||||
const windowWidth = window.innerWidth;
|
||||
|
||||
const getWindowSize = (width: number): ScreenSize => {
|
||||
let size!: ScreenSize;
|
||||
|
||||
switch (true) {
|
||||
case width < 640: {
|
||||
size = ScreenSize.SM;
|
||||
console.log('small')
|
||||
break;
|
||||
}
|
||||
case width >= 640: {
|
||||
size = ScreenSize.MD;
|
||||
|
||||
break;
|
||||
}
|
||||
case width >= 1024: {
|
||||
size = ScreenSize.LG
|
||||
|
||||
break;
|
||||
}
|
||||
case width >= 1280: {
|
||||
size = ScreenSize.XL;
|
||||
|
||||
break;
|
||||
}
|
||||
case width >= 1536: {
|
||||
size = ScreenSize.XXL;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
const onWindowResize = () => {
|
||||
const width: number = window.innerWidth;
|
||||
const newScreenSize: ScreenSize = getWindowSize(width);
|
||||
console.log(newScreenSize)
|
||||
setScreenSize(newScreenSize);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
console.log(windowWidth)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
onWindowResize()
|
||||
|
||||
window.addEventListener('resize', onWindowResize);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', onWindowResize);
|
||||
}
|
||||
}, [])
|
||||
|
||||
return {
|
||||
screenSize
|
||||
}
|
||||
}
|
||||
30
client/src/hooks/userRole/UseUserRole.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useOidc } from "../../auth/oidcConfig";
|
||||
import { UserRole } from "../../enums/userRole";
|
||||
|
||||
export const useUserRole = () => {
|
||||
const [userRole, setUserRole] = useState<UserRole>()
|
||||
const { isUserLoggedIn, decodedIdToken } = useOidc();
|
||||
|
||||
useEffect(() => {
|
||||
if (isUserLoggedIn && decodedIdToken) {
|
||||
const idTokenRoles: string[] = decodedIdToken!.roles as string[];
|
||||
|
||||
let newUserRole: string | undefined;
|
||||
|
||||
for (const key in UserRole) {
|
||||
if (UserRole[key as keyof typeof UserRole] === idTokenRoles[0]) {
|
||||
newUserRole = key;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setUserRole(UserRole[newUserRole! as keyof typeof UserRole]);
|
||||
}
|
||||
}, [decodedIdToken, isUserLoggedIn])
|
||||
|
||||
return {
|
||||
userRole
|
||||
}
|
||||
}
|
||||
25
client/src/httpClient/httpClient.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import axios, { AxiosInstance, CreateAxiosDefaults } from 'axios';
|
||||
import { getOidc } from '../auth/oidcConfig';
|
||||
|
||||
let config: CreateAxiosDefaults<any> = {
|
||||
baseURL: import.meta.env.VITE_API_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
|
||||
const httpClient: AxiosInstance = axios.create(config)
|
||||
|
||||
httpClient.interceptors.request.use(async (config) => {
|
||||
const oidc = await getOidc();
|
||||
|
||||
if (oidc.isUserLoggedIn) {
|
||||
const { accessToken } = await oidc.getTokens();
|
||||
|
||||
config.headers.Authorization = `Bearer ${accessToken}`;
|
||||
}
|
||||
|
||||
return config;
|
||||
});
|
||||
|
||||
export default httpClient;
|
||||
12
client/src/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>Flying</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
4
client/src/interfaces/Alert.interface.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export interface Alert {
|
||||
severity: 'danger' | 'default' | 'success' | 'warning';
|
||||
message: string;
|
||||
}
|
||||
19
client/src/main.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import App from './App.tsx';
|
||||
import AppContextProvider from './context/appContext/AppContextProvider.tsx';
|
||||
import LogbookContextProvider from './context/logbookContext/LogbookContextProvider.tsx'
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { OidcProvider } from './auth/oidcConfig.ts';
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<OidcProvider>
|
||||
<AppContextProvider>
|
||||
<LogbookContextProvider>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</LogbookContextProvider>
|
||||
</AppContextProvider>
|
||||
</OidcProvider>
|
||||
);
|
||||
12
client/src/styles.css
Normal file
@@ -0,0 +1,12 @@
|
||||
@import "tailwindcss";
|
||||
@plugin './hero.ts';
|
||||
/* Note: You may need to change the path to fit your project structure */
|
||||
@source '../../node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}';
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@theme inline {
|
||||
--color-primary: #000000;
|
||||
}
|
||||
|
||||
|
||||
10
client/src/tanstack.d.ts
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
import '@tanstack/react-table';
|
||||
|
||||
/* eslint-disable */
|
||||
declare module '@tanstack/react-table' {
|
||||
interface ColumnMeta<TData extends RowData, TValue> {
|
||||
align?: 'left' | 'center' | 'right';
|
||||
headerAlign?: 'left' | 'center' | 'right';
|
||||
}
|
||||
}
|
||||
/* eslint-enable */
|
||||
12
client/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
readonly VITE_CLIENT_ID: string;
|
||||
readonly VITE_TENANT_ID: string;
|
||||
readonly VITE_REDIRECT_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
25
client/tsconfig.json
Normal file
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
11
client/tsconfig.node.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
26
client/vite.config.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
|
||||
// https://vitejs.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
tailwindcss()
|
||||
],
|
||||
preview: {
|
||||
port: 8080,
|
||||
strictPort: true
|
||||
},
|
||||
server: {
|
||||
port: 8080,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
// rewrite: (path) => path.replace(/^\/api/, '')
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||