adding logbook entry delete #31

Merged
noahspannbauer merged 1 commits from feature/27-logbook-delete-entry into main 2024-11-19 21:35:56 -05:00
14 changed files with 437 additions and 103 deletions

View File

@@ -1,6 +1,7 @@
import { import {
Body, Body,
Controller, Controller,
Delete,
Get, Get,
HttpException, HttpException,
Param, Param,
@@ -79,4 +80,17 @@ export class LogbookController {
}); });
} }
} }
@Delete(':rowKey')
async delete(@Param() params: any): Promise<void> {
try {
await this.logbookService.delete(params.rowKey);
} catch (error) {
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode, {
cause: customError.name
});
}
}
} }

View File

@@ -10,19 +10,19 @@ export class LogbookEntity {
routeTo: string; routeTo: string;
durationOfFlight: number | null; durationOfFlight: number | null;
singleEngineLand: number | null; singleEngineLand: number | null;
simulatorAtd: number | null; simulatorAtd?: number | null;
landingsDay: number | null; landingsDay?: number | null;
landingsNight: number | null; landingsNight?: number | null;
groundTrainingReceived: number; groundTrainingReceived?: number;
flightTrainingReceived: number; flightTrainingReceived?: number;
crossCountry: number | null; crossCountry?: number | null;
night: number | null; night?: number | null;
solo: number | null; solo?: number | null;
pilotInCommand: number | null; pilotInCommand?: number | null;
instrumentActual: number | null; instrumentActual?: number | null;
instrumentSimulated: number | null; instrumentSimulated?: number | null;
instrumentApproaches: number | null; instrumentApproaches?: number | null;
instrumentHolds: number | null; instrumentHolds?: number | null;
instrumentNavTrack: number | null; instrumentNavTrack?: number | null;
notes: string; notes?: string;
} }

View File

@@ -73,7 +73,7 @@ export class LogbookService {
instrumentNavTrack: entity.instrumentNavTrack instrumentNavTrack: entity.instrumentNavTrack
? Number(entity.instrumentNavTrack) ? Number(entity.instrumentNavTrack)
: null, : null,
notes: entity.notes.toString() notes: entity.notes ? entity.notes.toString() : ''
}; };
logbookEntries.push(logbookEntry); logbookEntries.push(logbookEntry);
@@ -143,6 +143,7 @@ export class LogbookService {
} }
async update(logbookData: LogbookDto): Promise<void> { async update(logbookData: LogbookDto): Promise<void> {
try {
const client: TableClient = await this.tableService.getTableClient( const client: TableClient = await this.tableService.getTableClient(
this.tableName this.tableName
); );
@@ -150,7 +151,6 @@ export class LogbookService {
Object.assign(logbook, logbookData); Object.assign(logbook, logbookData);
try {
await client.upsertEntity(logbook, 'Replace'); await client.upsertEntity(logbook, 'Replace');
} catch (error) { } catch (error) {
const restError: RestError = error as RestError; const restError: RestError = error as RestError;
@@ -162,4 +162,22 @@ export class LogbookService {
); );
} }
} }
async delete(rowKey: string): Promise<void> {
try {
const client: TableClient = await this.tableService.getTableClient(
this.tableName
);
await client.deleteEntity('entry', rowKey);
} catch (error) {
const restError: RestError = error as RestError;
throw new CustomError(
restError.details['odataError']['message']['value'],
restError.details['odataError']['code'],
restError.statusCode
);
}
}
} }

View File

@@ -11,9 +11,9 @@ import {
PenIcon, PenIcon,
TrashIcon TrashIcon
} from '@noahspan/noahspan-components'; } from '@noahspan/noahspan-components';
import { FormMode } from '../enums/formMode'; import { FormMode } from '../../enums/formMode';
const ActionMenu = ({ id, onOpenCloseForm }: IActionMenuProps) => { const ActionMenu = ({ id, onDelete, onOpenCloseForm }: IActionMenuProps) => {
const [anchorElAction, setAnchorElAction] = useState<null | HTMLElement>( const [anchorElAction, setAnchorElAction] = useState<null | HTMLElement>(
null null
); );
@@ -50,7 +50,7 @@ const ActionMenu = ({ id, onOpenCloseForm }: IActionMenuProps) => {
<ListItemText>View</ListItemText> <ListItemText>View</ListItemText>
</MenuItem> </MenuItem>
<hr className="my-3" /> <hr className="my-3" />
<MenuItem> <MenuItem onClick={() => onDelete(id)}>
<ListItemIcon> <ListItemIcon>
<TrashIcon size="lg" /> <TrashIcon size="lg" />
</ListItemIcon> </ListItemIcon>

View File

@@ -1,6 +1,7 @@
import { FormMode } from '../enums/formMode'; import { FormMode } from '../../enums/formMode';
export interface IActionMenuProps { export interface IActionMenuProps {
id: string; id: string;
onDelete: (entryId: string) => void;
onOpenCloseForm: (formMode: FormMode, id: string) => void; onOpenCloseForm: (formMode: FormMode, id: string) => void;
} }

View File

@@ -0,0 +1,52 @@
import {
Box,
Button,
CircleCheckIcon,
Dialog,
DialogActions,
DialogContent,
DialogContentText,
DialogTitle,
Spinner,
XmarkIcon
} from '@noahspan/noahspan-components';
import { IDialogConfirmationProps } from './IConfirmationDialogProps';
const ConfirmationDialog = ({
contentText,
isLoading,
isOpen,
onCancel,
onConfirm,
title
}: IDialogConfirmationProps) => {
return (
<Dialog
onClose={onCancel}
open={isOpen}
sx={{
'& .MuiDialog-paper': { width: '2000px' }
}}
>
<DialogTitle>{title}</DialogTitle>
<DialogContent sx={{ textAlign: 'center' }}>
{!isLoading && <DialogContentText>{contentText}</DialogContentText>}
{isLoading && <Spinner />}
</DialogContent>
<DialogActions>
<Button onClick={onCancel} variant="outlined" startIcon={<XmarkIcon />}>
No
</Button>
<Button
onClick={onConfirm}
variant="contained"
startIcon={<CircleCheckIcon />}
>
Yes
</Button>
</DialogActions>
</Dialog>
);
};
export default ConfirmationDialog;

View File

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

View File

@@ -0,0 +1,27 @@
export interface ILogbookEntry {
partitionKey: string;
rowKey: string;
id: string;
pilotId: string;
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;
}

View File

@@ -0,0 +1,13 @@
import { FormMode } from '../../enums/formMode';
import { ILogbookEntry } from './ILogbookEntry';
export interface ILogbookState {
entries: ILogbookEntry[];
formMode: FormMode;
error: string | undefined;
isConfirmDialogLoading: boolean;
isConfirmDialogOpen: boolean;
isFormOpen: boolean;
isLoading: boolean;
selectedEntryId: string | undefined;
}

View File

@@ -1,75 +1,130 @@
import { useEffect, useState } from 'react'; import { useEffect, useReducer, useState } from 'react';
import LogbookEntryForm from '../logbookEntryForm/LogbookEntryForm'; import LogbookEntryForm from '../logbookEntryForm/LogbookEntryForm';
import { import {
Alert,
Box, Box,
Button, Button,
ColumnDef, ColumnDef,
Grid, Grid,
PlusIcon, PlusIcon,
Spinner,
Table, Table,
Typography Typography
} from '@noahspan/noahspan-components'; } from '@noahspan/noahspan-components';
import { initialState, reducer } from './reducer';
import { useHttpClient } from '../../hooks/httpClient/UseHttpClient'; import { useHttpClient } from '../../hooks/httpClient/UseHttpClient';
import { AxiosInstance, AxiosResponse } from 'axios'; import { AxiosError, AxiosInstance, AxiosResponse } from 'axios';
import { useAccessToken } from '../../hooks/accessToken/UseAcessToken'; import { useAccessToken } from '../../hooks/accessToken/UseAcessToken';
import { useIsAuthenticated } from '@azure/msal-react'; import { useIsAuthenticated } from '@azure/msal-react';
import { FormMode } from '../../enums/formMode'; import { FormMode } from '../../enums/formMode';
import ActionMenu from '../../actionMenu/ActionMenu'; import ActionMenu from '../actionMenu/ActionMenu';
import ConfirmationDialog from '../confirmationDialog/ConfirmationDialog';
type LogbookEntry = { import { ILogbookEntry } from './ILogbookEntry';
partitionKey: string;
rowKey: string;
id: string;
pilotId: string;
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;
};
const Logbook: React.FC<unknown> = () => { const Logbook: React.FC<unknown> = () => {
const [state, dispatch] = useReducer(reducer, initialState);
const httpClient: AxiosInstance = useHttpClient(); const httpClient: AxiosInstance = useHttpClient();
const isAuthenticated = useIsAuthenticated(); const isAuthenticated = useIsAuthenticated();
const { getAccessToken } = useAccessToken(); const { getAccessToken } = useAccessToken();
const [isDrawerOpen, setIsDrawerOpen] = useState(false);
const [entryFormMode, setEntryFormMode] = useState<FormMode>(FormMode.CANCEL); const getLogbookEntries = async () => {
const [selectedEntryId, setSelectedEntryId] = useState<string | undefined>(); try {
const [entries, setEntries] = useState([]); dispatch({ type: 'SET_IS_LOADING', payload: true });
const token = await getAccessToken();
const config = isAuthenticated
? { headers: { Authorization: `${token}` } }
: {};
const response: AxiosResponse = await httpClient.get(
`api/logbook`,
config
);
dispatch({ type: 'SET_ENTRIES', payload: response.data });
} catch (error) {
const axiosError = error as AxiosError;
dispatch({
type: 'SET_ERROR',
payload: `Loading of logbook entries failed with the following message: ${axiosError.message}`
});
} finally {
dispatch({ type: 'SET_IS_LOADING', payload: false });
}
};
const onOpenCloseEntryForm = (mode: FormMode, entryId?: string) => { const onOpenCloseEntryForm = (mode: FormMode, entryId?: string) => {
switch (mode) { switch (mode) {
case FormMode.ADD: case FormMode.ADD:
case FormMode.EDIT: case FormMode.EDIT:
case FormMode.VIEW: case FormMode.VIEW:
setEntryFormMode(mode); dispatch({
setSelectedEntryId(entryId); type: 'SET_OPEN_CLOSE_ENTRY_FORM',
setIsDrawerOpen(true); payload: {
formMode: mode,
selectedEntryId: entryId,
isFormOpen: true
}
});
break; break;
case FormMode.CANCEL: case FormMode.CANCEL:
setEntryFormMode(mode); dispatch({
setSelectedEntryId(undefined); type: 'SET_OPEN_CLOSE_ENTRY_FORM',
setIsDrawerOpen(false); payload: {
formMode: mode,
selectedEntryId: undefined,
isFormOpen: false
}
});
break; break;
} }
}; };
const columns: ColumnDef<LogbookEntry>[] = [ const onDeleteEntry = (entryId: string) => {
dispatch({
type: 'SET_DELETE',
payload: { isConfirmationDialogOpen: true, selectedEntryId: entryId }
});
};
const onConfirmationDialogConfirm = async () => {
try {
dispatch({ type: 'SET_IS_CONFIRMATION_DIALOG_LOADING', payload: true });
const token = await getAccessToken();
const config = isAuthenticated
? { headers: { Authorization: `${token}` } }
: {};
await httpClient.delete(`api/logbook/${state.selectedEntryId}`, config);
dispatch({
type: 'SET_DELETE',
payload: { isConfirmationDialogOpen: false, selectedEntryId: undefined }
});
await getLogbookEntries();
} catch (error) {
const axiosError = error as AxiosError;
dispatch({
type: 'SET_ERROR',
payload: `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: { isConfirmationDialogOpen: false, selectedEntryId: undefined }
});
};
const columns: ColumnDef<ILogbookEntry>[] = [
{ {
accessorKey: 'pilotName', accessorKey: 'pilotName',
header: 'Pilot' header: 'Pilot'
@@ -303,6 +358,7 @@ const Logbook: React.FC<unknown> = () => {
cell: (info) => ( cell: (info) => (
<ActionMenu <ActionMenu
id={info.row.original.rowKey} id={info.row.original.rowKey}
onDelete={onDeleteEntry}
onOpenCloseForm={onOpenCloseEntryForm} onOpenCloseForm={onOpenCloseEntryForm}
/> />
) )
@@ -310,27 +366,10 @@ const Logbook: React.FC<unknown> = () => {
]; ];
useEffect(() => { useEffect(() => {
const getLogbookEntries = async () => { if (isAuthenticated && !state.isFormOpen) {
try {
const token = await getAccessToken();
const config = isAuthenticated
? { headers: { Authorization: `${token}` } }
: {};
const response: AxiosResponse = await httpClient.get(
`api/logbook`,
config
);
setEntries(response.data);
} catch (error) {
console.log(error);
}
};
if (isAuthenticated && !isDrawerOpen) {
getLogbookEntries(); getLogbookEntries();
} }
}, [isAuthenticated, isDrawerOpen]); }, [isAuthenticated, state.isFormOpen]);
return ( return (
<Box sx={{ margin: '20px' }}> <Box sx={{ margin: '20px' }}>
@@ -348,16 +387,51 @@ const Logbook: React.FC<unknown> = () => {
Add Entry Add Entry
</Button> </Button>
</Grid> </Grid>
<Grid size={12}> {!state.isLoading && state.error && (
{entries.length > 0 && <Table columns={columns} data={entries} />} <Grid display="flex" justifyContent="center" size={12}>
<Alert
onClose={() =>
dispatch({ type: 'SET_ERROR', payload: undefined })
}
severity="error"
sx={{ width: '100%' }}
>
{state.error}
</Alert>
</Grid> </Grid>
)}
{!state.isLoading && (
<Grid size={12}>
{state.entries.length > 0 && (
<Table columns={columns} data={state.entries} />
)}
</Grid>
)}
{state.isLoading && !state.error && (
<>
<Grid display="flex" justifyContent="center" size={12}>
<Spinner />
</Grid>
<Grid display="flex" justifyContent="center" size={12}>
Loading...
</Grid>
</>
)}
</Grid> </Grid>
<LogbookEntryForm <LogbookEntryForm
entryId={selectedEntryId} entryId={state.selectedEntryId}
isDrawerOpen={isDrawerOpen} isDrawerOpen={state.isFormOpen}
mode={entryFormMode} mode={state.formMode}
onOpenClose={(mode) => onOpenCloseEntryForm(mode)} onOpenClose={(mode) => onOpenCloseEntryForm(mode)}
/> />
<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"
/>
</Box> </Box>
); );
}; };

View File

@@ -0,0 +1,92 @@
import { FormMode } from '../../enums/formMode';
import { ILogbookEntry } from './ILogbookEntry';
import { ILogbookState } from './ILogbookState';
type Action =
| {
type: 'SET_DELETE';
payload: {
isConfirmationDialogOpen: boolean;
selectedEntryId: string | undefined;
};
}
| { type: 'SET_ENTRIES'; payload: ILogbookEntry[] }
| { type: 'SET_ERROR'; payload: string | 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;
selectedEntryId: string | undefined;
isFormOpen: boolean;
};
};
export const initialState: ILogbookState = {
entries: [],
error: undefined,
formMode: FormMode.CANCEL,
isConfirmDialogLoading: false,
isConfirmDialogOpen: false,
isFormOpen: false,
isLoading: false,
selectedEntryId: undefined
};
export const reducer = (
state: ILogbookState,
action: Action
): ILogbookState => {
switch (action.type) {
case 'SET_DELETE': {
return {
...state,
isConfirmDialogOpen: action.payload.isConfirmationDialogOpen,
selectedEntryId: action.payload.selectedEntryId
};
}
case 'SET_ENTRIES': {
return {
...state,
entries: action.payload
};
}
case 'SET_ERROR': {
return {
...state,
error: 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,
selectedEntryId: action.payload.selectedEntryId
};
}
default: {
return state;
}
}
};

View File

@@ -1,4 +1,5 @@
export interface ILogbookEntryFormState { export interface ILogbookEntryFormState {
error: string | undefined;
isDisabled: boolean; isDisabled: boolean;
isLoading: boolean; isLoading: boolean;
pilotOptions: { label: string; value: string }[]; pilotOptions: { label: string; value: string }[];

View File

@@ -3,6 +3,7 @@ import {
Accordion, Accordion,
AccordionDetails, AccordionDetails,
AccordionSummary, AccordionSummary,
Alert,
Button, Button,
ChevronDownIcon, ChevronDownIcon,
DatePicker, DatePicker,
@@ -18,7 +19,7 @@ import {
import { useForm, Controller, FormProvider } from 'react-hook-form'; import { useForm, Controller, FormProvider } from 'react-hook-form';
import { ILogbookEntryFormProps } from './ILogbookEntryFormProps'; import { ILogbookEntryFormProps } from './ILogbookEntryFormProps';
import { initialState, reducer } from './reducer'; import { initialState, reducer } from './reducer';
import axios, { AxiosInstance, AxiosResponse } from 'axios'; import axios, { AxiosError, AxiosInstance, AxiosResponse } from 'axios';
import { useHttpClient } from '../../hooks/httpClient/UseHttpClient'; import { useHttpClient } from '../../hooks/httpClient/UseHttpClient';
import { useAccessToken } from '../../hooks/accessToken/UseAcessToken'; import { useAccessToken } from '../../hooks/accessToken/UseAcessToken';
import { useIsAuthenticated } from '@azure/msal-react'; import { useIsAuthenticated } from '@azure/msal-react';
@@ -96,12 +97,9 @@ const LogbookEntryForm: React.FC<ILogbookEntryFormProps> = ({
dispatch({ type: 'SET_IS_DISABLED', payload: false }); dispatch({ type: 'SET_IS_DISABLED', payload: false });
onOpenClose(FormMode.CANCEL); onOpenClose(FormMode.CANCEL);
} catch (error) { } catch (error) {
if (axios.isAxiosError(error)) { const axiosError = error as AxiosError;
const errResp = error.response;
console.log(errResp); dispatch({ type: 'SET_ERROR', payload: axiosError.message });
} else {
}
} finally { } finally {
dispatch({ type: 'SET_IS_LOADING', payload: false }); dispatch({ type: 'SET_IS_LOADING', payload: false });
} }
@@ -129,13 +127,15 @@ const LogbookEntryForm: React.FC<ILogbookEntryFormProps> = ({
methods.reset(entry); methods.reset(entry);
} catch (error) { } catch (error) {
console.log(error); const axiosError = error as AxiosError;
dispatch({ type: 'SET_ERROR', payload: axiosError.message });
} finally { } finally {
dispatch({ type: 'SET_IS_LOADING', payload: false }); dispatch({ type: 'SET_IS_LOADING', payload: false });
} }
}; };
if (entryId) { if (entryId && isDrawerOpen) {
getEntry(); getEntry();
} }
}, [entryId]); }, [entryId]);
@@ -175,6 +175,19 @@ const LogbookEntryForm: React.FC<ILogbookEntryFormProps> = ({
<XmarkIcon /> <XmarkIcon />
</IconButton> </IconButton>
</Grid> </Grid>
{state.error && (
<Grid display="flex" justifyContent="center" size={12}>
<Alert
onClose={() =>
dispatch({ type: 'SET_ERROR', payload: undefined })
}
severity="error"
sx={{ width: '100%' }}
>
{state.error}
</Alert>
</Grid>
)}
<Grid alignItems="center" display="flex" size={4}> <Grid alignItems="center" display="flex" size={4}>
<Typography variant="body1">Pilot *</Typography> <Typography variant="body1">Pilot *</Typography>
</Grid> </Grid>
@@ -200,7 +213,17 @@ const LogbookEntryForm: React.FC<ILogbookEntryFormProps> = ({
<Select <Select
disabled={state.isDisabled} disabled={state.isDisabled}
fullWidth fullWidth
onChange={onChange} onChange={(event) => {
const pilot = pilots?.find(
(pilot) => (pilot.id = event.target.value)
);
if (pilot) {
methods.setValue('pilotName', pilot.name);
}
methods.setValue('pilotId', event.target.value);
}}
options={ options={
state.pilotOptions && state.pilotOptions.length > 0 state.pilotOptions && state.pilotOptions.length > 0
? state.pilotOptions ? state.pilotOptions

View File

@@ -1,12 +1,14 @@
import { ILogbookEntryFormState } from './ILogbookEntryFormState'; import { ILogbookEntryFormState } from './ILogbookEntryFormState';
type Action = type Action =
| { type: 'SET_ERROR'; payload: string | undefined }
| { type: 'SET_IS_DISABLED'; payload: boolean } | { type: 'SET_IS_DISABLED'; payload: boolean }
| { type: 'SET_IS_LOADING'; payload: boolean } | { type: 'SET_IS_LOADING'; payload: boolean }
| { type: 'SET_PILOT_OPTIONS'; payload: { label: string; value: string }[] } | { type: 'SET_PILOT_OPTIONS'; payload: { label: string; value: string }[] }
| { type: 'SET_SELECTED_ENTRY_PILOT_NAME'; payload: string }; | { type: 'SET_SELECTED_ENTRY_PILOT_NAME'; payload: string };
export const initialState: ILogbookEntryFormState = { export const initialState: ILogbookEntryFormState = {
error: undefined,
isDisabled: false, isDisabled: false,
isLoading: true, isLoading: true,
pilotOptions: [], pilotOptions: [],
@@ -18,6 +20,12 @@ export const reducer = (
action: Action action: Action
): ILogbookEntryFormState => { ): ILogbookEntryFormState => {
switch (action.type) { switch (action.type) {
case 'SET_ERROR': {
return {
...state,
error: action.payload
};
}
case 'SET_IS_DISABLED': { case 'SET_IS_DISABLED': {
return { return {
...state, ...state,
@@ -42,5 +50,8 @@ export const reducer = (
selectedEntryPilotName: action.payload selectedEntryPilotName: action.payload
}; };
} }
default: {
return state;
}
} }
}; };