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
This commit was merged in pull request #97.
This commit is contained in:
2025-11-23 10:42:31 -06:00
committed by GitHub
parent f94d0f7ca9
commit f98a2ab127
208 changed files with 27135 additions and 16875 deletions

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

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

View File

@@ -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;

View File

@@ -0,0 +1,8 @@
export interface DialogConfirmationProps {
contentText: string;
isLoading: boolean;
isOpen: boolean;
onCancel: () => void;
onConfirm: () => void;
title: string;
}

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

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

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

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

View File

@@ -0,0 +1,8 @@
import { FormMode } from '../../enums/formMode';
export interface LogFormProps {
logId?: string;
// isDrawerOpen: boolean;
mode: FormMode;
// onOpenClose: (mode: FormMode) => void;
}

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

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

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

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

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

View 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
]

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

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

View File

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

View 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

View File

@@ -0,0 +1,5 @@
import { FormMode } from "../../enums/formMode";
export interface LogbookDrawerProps {
onOpenClose: (mode: FormMode) => void;
}

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

View File

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

View File

@@ -0,0 +1,8 @@
import { FormMode } from '../../enums/formMode';
export interface IPilotFormProps {
isDrawerOpen: boolean;
mode: FormMode;
onOpenClose: (mode: FormMode) => void;
pilotId?: string;
}

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

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

View File

@@ -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;

View File

@@ -0,0 +1,6 @@
import { FormMode } from "../../enums/formMode";
export interface PilotFormCertificatesProps {
isDisabled: boolean;
mode: FormMode;
}

View File

@@ -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;

View File

@@ -0,0 +1,6 @@
import { FormMode } from "../../enums/formMode";
export interface PilotFormEndorsementsProps {
isDisabled: boolean;
mode: FormMode;
}

View 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

View File

@@ -0,0 +1,3 @@
export interface PilotFormMedicalProps {
isDisabled: boolean;
}

View File

@@ -0,0 +1,10 @@
export interface Pilot {
id: string;
name: string;
address: string;
city: string;
state: string;
postalCode: string;
email: string;
phone: string;
};

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

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

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

View File

@@ -0,0 +1,7 @@
import { InteractionStatus } from '@azure/msal-browser';
export interface ISiteNavProps {
handleSignIn: () => void;
handleSignOut: () => void;
inProgress: InteractionStatus;
}

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

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

View 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='&copy; <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;

View File

@@ -0,0 +1,5 @@
export interface TrackMapProps {
height: string;
logId: string;
tracks: {id: string; order: number; url: string}[];
}

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

View File

@@ -0,0 +1,8 @@
import { FormMode } from "../../enums/formMode";
export interface TracksFormProps {
isDrawerOpen: boolean;
mode: FormMode;
onOpenClose: (mode: FormMode) => void;
selectedLogId: string | undefined;
}

View File

@@ -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; }[];
}

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