From 485671f163d6f8e0d391dab0b209f8d92766cd1a Mon Sep 17 00:00:00 2001 From: Noah Spannbauer Date: Fri, 21 Mar 2025 19:21:35 -0500 Subject: [PATCH] adding tracks --- api/package.json | 5 +- api/src/app.module.ts | 5 - api/src/file/file.service.ts | 44 ++ api/src/log/interceptors/log.interceptor.ts | 2 + api/src/log/log.controller.ts | 41 +- api/src/log/log.dto.ts | 1 + api/src/log/log.entity.ts | 1 + api/src/log/log.module.ts | 7 +- app/package.json | 2 +- app/src/components/actionMenu/ActionMenu.tsx | 11 +- .../actionMenu/IActionMenuProps.tsx | 1 + app/src/components/logForm/LogForm.tsx | 2 +- app/src/components/logTracks/LogTracks.tsx | 170 ++++++++ .../logTracks/LogTracksProps.interface.ts | 8 + app/src/components/logbook/ILogbookEntry.ts | 3 + app/src/components/logbook/ILogbookState.ts | 4 + app/src/components/logbook/Logbook.tsx | 376 +++++------------- app/src/components/logbook/columns.tsx | 252 ++++++++++++ app/src/components/logbook/reducer.ts | 24 +- docker-compose.yaml | 2 +- infrastructure/config/main.tf | 5 + infrastructure/config/outputs.tf | 4 + infrastructure/main.tf | 1 + pnpm-lock.yaml | 62 ++- 24 files changed, 731 insertions(+), 302 deletions(-) create mode 100644 api/src/file/file.service.ts create mode 100644 app/src/components/logTracks/LogTracks.tsx create mode 100644 app/src/components/logTracks/LogTracksProps.interface.ts create mode 100644 app/src/components/logbook/columns.tsx diff --git a/api/package.json b/api/package.json index 4c40a6a..2608c99 100644 --- a/api/package.json +++ b/api/package.json @@ -19,6 +19,7 @@ "test:e2e": "jest --config ./test/jest-e2e.json" }, "dependencies": { + "@azure/storage-blob": "^12.27.0", "@microsoft/microsoft-graph-client": "^3.0.7", "@nestjs/axios": "^3.0.3", "@nestjs/common": "^10.0.0", @@ -29,10 +30,12 @@ "@noahspan/azure-database": "^3.1.2", "@noahspan/noahspan-modules": "^1.1.5", "@schematics/angular": "^17.3.7", + "@types/multer": "^1.4.12", "dotenv": "^16.4.7", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1", - "uuid": "^10.0.0" + "uuid": "^10.0.0", + "uuidv4": "^6.2.13" }, "devDependencies": { "@microsoft/microsoft-graph-types": "^2.40.0", diff --git a/api/src/app.module.ts b/api/src/app.module.ts index 0cadb5e..a4ac52f 100644 --- a/api/src/app.module.ts +++ b/api/src/app.module.ts @@ -44,11 +44,6 @@ import configuration from './config/configuration'; provide: APP_FILTER, useClass: HttpExceptionFilter }, - // { - // provide: APP_GUARD, - // useClass: AuthGuard - // }, - // Reflector ] }) export class AppModule {} diff --git a/api/src/file/file.service.ts b/api/src/file/file.service.ts new file mode 100644 index 0000000..dadd437 --- /dev/null +++ b/api/src/file/file.service.ts @@ -0,0 +1,44 @@ +import { BlobServiceClient, BlockBlobClient } from '@azure/storage-blob'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() export class FileService { + constructor(private readonly configService: ConfigService) {} + + private containerName: string; + + async getBlobServiceInstance() { + const connectionString = this.configService.get('azureStorageConnectionString'); + const blobServiceClient: BlobServiceClient = await BlobServiceClient.fromConnectionString(connectionString) + + return blobServiceClient; + } + + async getBlobClient(fileName: string): Promise { + const blobService = await this.getBlobServiceInstance(); + const containerName = this.containerName; + const containerClient = blobService.getContainerClient(containerName); + const blockBlobClient = containerClient.getBlockBlobClient(fileName); + + return blockBlobClient; + } + + async uploadFile(file: Express.Multer.File, containerName: string, rowKey: string) { + this.containerName = containerName; + + const blockBlobClient = await this.getBlobClient(`${rowKey}/${file.originalname}`); + const fileUrl = blockBlobClient.url; + + await blockBlobClient.uploadData(file.buffer); + + return fileUrl; + } + + async deleteFile(containerName: string, rowKey:string, fileName: string) { + this.containerName = containerName; + + const blockBlobClient = await this.getBlobClient(`${rowKey}/${fileName}`); + + await blockBlobClient.deleteIfExists(); + } +} \ No newline at end of file diff --git a/api/src/log/interceptors/log.interceptor.ts b/api/src/log/interceptors/log.interceptor.ts index c6c1c6a..ef95f79 100644 --- a/api/src/log/interceptors/log.interceptor.ts +++ b/api/src/log/interceptors/log.interceptor.ts @@ -6,6 +6,7 @@ export class LogInterceptor implements NestInterceptor { const req = context.switchToHttp().getRequest(); const authHeader = req.headers.authorization; const token = authHeader && authHeader.split(' ')[1]; + console.log(token) if (!token) { return handler.handle().pipe( @@ -22,6 +23,7 @@ export class LogInterceptor implements NestInterceptor { routeFrom: log.routeFrom, routeTo: log.routeTo, durationOfFlight: log.durationOfFlight, + tracks: log.tracks, notes: log.notes }; }); diff --git a/api/src/log/log.controller.ts b/api/src/log/log.controller.ts index 5a23a88..4ce4d36 100644 --- a/api/src/log/log.controller.ts +++ b/api/src/log/log.controller.ts @@ -7,6 +7,8 @@ import { Param, Post, Put, + Query, + UploadedFile, UseGuards, UseInterceptors } from '@nestjs/common'; @@ -16,12 +18,16 @@ import { LogService } from './log.service'; import { CustomError } from '../error/customError'; import { AuthGuard } from '@noahspan/noahspan-modules'; import { LogInterceptor } from './interceptors/log.interceptor'; - +import { FileService } from '../file/file.service'; +import { FileInterceptor } from '@nestjs/platform-express'; @Controller('logs') @UseInterceptors(new LogInterceptor()) export class LogController { - constructor(private readonly logService: LogService) {} + constructor( + private readonly fileService: FileService, + private readonly logService: LogService + ) {} @Get(':partitionKey/:rowKey') async find( @@ -98,4 +104,35 @@ export class LogController { throw new HttpException(customError.message, customError.statusCode); } } + + // @UseGuards(AuthGuard) + @Post(':partitionKey/:rowKey/track') + @UseInterceptors(FileInterceptor('file')) + async createTrack(@Param('rowKey') rowKey: string, @UploadedFile() file: Express.Multer.File) { + try { + const containerName = 'tracks'; + const url = await this.fileService.uploadFile(file, containerName, rowKey); + + return { url } + } catch (error) { + const customError = error as CustomError; + + throw new HttpException(customError.message, customError.statusCode); + } + } + + @UseGuards(AuthGuard) + @Delete(':partitionKey/:rowKey/track') + async deleteTrack(@Param('rowKey') rowKey: string, @Query('fileName') fileName: string): Promise { + try { + console.log(fileName) + const containerName = 'tracks'; + + return await this.fileService.deleteFile(containerName, rowKey, fileName) + } catch (error) { + const customError = error as CustomError; + + throw new HttpException(customError.message, customError.statusCode); + } + } } diff --git a/api/src/log/log.dto.ts b/api/src/log/log.dto.ts index 735a195..9d82968 100644 --- a/api/src/log/log.dto.ts +++ b/api/src/log/log.dto.ts @@ -22,5 +22,6 @@ export class LogDto { night: number; solo: number; pilotInCommand: number; + tracks: string[]; notes: string; } diff --git a/api/src/log/log.entity.ts b/api/src/log/log.entity.ts index ff72b34..a0f9179 100644 --- a/api/src/log/log.entity.ts +++ b/api/src/log/log.entity.ts @@ -24,5 +24,6 @@ export class Log { instrumentApproaches?: number | null; instrumentHolds?: number | null; instrumentNavTrack?: number | null; + tracks?: string[]; notes?: string; } diff --git a/api/src/log/log.module.ts b/api/src/log/log.module.ts index a1343d4..111ada4 100644 --- a/api/src/log/log.module.ts +++ b/api/src/log/log.module.ts @@ -4,6 +4,7 @@ import { LogService } from './log.service'; import { AzureTableStorageModule } from '@noahspan/azure-database'; import { Log } from './log.entity'; import { ConfigModule, ConfigService } from '@nestjs/config'; +import { FileService } from '../file/file.service'; @Module({ imports: [ @@ -22,6 +23,10 @@ import { ConfigModule, ConfigService } from '@nestjs/config'; }), ], controllers: [LogController], - providers: [LogService] + providers: [ + ConfigService, + FileService, + LogService + ] }) export class LogModule {} diff --git a/app/package.json b/app/package.json index c1306b6..46c489d 100644 --- a/app/package.json +++ b/app/package.json @@ -13,7 +13,7 @@ "dependencies": { "@azure/msal-browser": "^4.0.1", "@azure/msal-react": "^3.0.1", - "@noahspan/noahspan-components": "^1.5.1", + "@noahspan/noahspan-components": "^1.6.0", "axios": "^1.7.2", "dotenv": "^16.4.7", "react": "^18.3.1", diff --git a/app/src/components/actionMenu/ActionMenu.tsx b/app/src/components/actionMenu/ActionMenu.tsx index 40f0280..2fc1e93 100644 --- a/app/src/components/actionMenu/ActionMenu.tsx +++ b/app/src/components/actionMenu/ActionMenu.tsx @@ -12,7 +12,7 @@ import { import { FormMode } from '../../enums/formMode'; import { useIsAuthenticated } from '@azure/msal-react'; -const ActionMenu = ({ id, onDelete, onOpenCloseForm }: IActionMenuProps) => { +const ActionMenu = ({ id, onDelete, onOpenCloseForm, onOpenCloseTracks }: IActionMenuProps) => { const [anchorElAction, setAnchorElAction] = useState( null ); @@ -38,12 +38,21 @@ const ActionMenu = ({ id, onDelete, onOpenCloseForm }: IActionMenuProps) => { onClose={onCloseActionMenu} > {isAuthenticated && + <> onOpenCloseForm(FormMode.EDIT, id)}> Edit + onOpenCloseTracks(FormMode.EDIT, id)}> + + + + Tracks + + + } onOpenCloseForm(FormMode.VIEW, id)}> diff --git a/app/src/components/actionMenu/IActionMenuProps.tsx b/app/src/components/actionMenu/IActionMenuProps.tsx index bd5f036..9aa0221 100644 --- a/app/src/components/actionMenu/IActionMenuProps.tsx +++ b/app/src/components/actionMenu/IActionMenuProps.tsx @@ -4,4 +4,5 @@ export interface IActionMenuProps { id: string; onDelete: (entryId: string) => void; onOpenCloseForm: (formMode: FormMode, id: string) => void; + onOpenCloseTracks: (formMode: FormMode, id: string) => void; } diff --git a/app/src/components/logForm/LogForm.tsx b/app/src/components/logForm/LogForm.tsx index a7ff75c..e379045 100644 --- a/app/src/components/logForm/LogForm.tsx +++ b/app/src/components/logForm/LogForm.tsx @@ -174,7 +174,7 @@ const LogForm: React.FC = ({ }} > -
+ {`${mode.toString().toLowerCase().charAt(0).toUpperCase() + mode.toString().slice(1).toLowerCase()} Entry`} diff --git a/app/src/components/logTracks/LogTracks.tsx b/app/src/components/logTracks/LogTracks.tsx new file mode 100644 index 0000000..dc2a566 --- /dev/null +++ b/app/src/components/logTracks/LogTracks.tsx @@ -0,0 +1,170 @@ +import { useEffect, useState } from "react"; +import { Button, Drawer, Grid, Icon, IconButton, IconName, TextField, theme, Typography, useMediaQuery } from "@noahspan/noahspan-components"; +import { useHttpClient } from "../../hooks/httpClient/UseHttpClient"; +import { AxiosInstance, AxiosResponse } from "axios"; +import { useAccessToken } from "../../hooks/accessToken/UseAcessToken"; +import { LogTracksProps } from "./LogTracksProps.interface"; +import { FormMode } from "../../enums/formMode"; +import { useIsAuthenticated } from "@azure/msal-react"; +import { ILogbookEntry } from "../logbook/ILogbookEntry"; + +const LogTracks = ({ isDrawerOpen, mode, onOpenClose, selectedRowKey }: LogTracksProps) => { + const [tracks, setTracks] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const httpClient: AxiosInstance = useHttpClient(); + const { getAccessToken } = useAccessToken(); + const isAuthenticated = useIsAuthenticated(); + + const isMedium = useMediaQuery(theme.breakpoints.up('md')); + + const getConfig = async () => { + const config = isAuthenticated + ? { headers: { Authorization: await getAccessToken() } } + : {}; + + return config + } + + const getLog = async (): Promise => { + const logResponse: AxiosResponse = await httpClient.get( + `api/logs/log/${selectedRowKey}`, + await getConfig() + ); + const logData: ILogbookEntry = logResponse.data; + + return logData + } + + const handleFileUpload = async (event: React.ChangeEvent) => { + try { + setIsLoading(true); + + const file = event.target.files![0] + const formData = new FormData(); + const config = await getConfig(); + const formDataConfig = { + headers: { + ...config.headers, + 'Content-Type': 'multipart/form-data' + } + } + + formData.append('file', file); + + const uploadResponse: AxiosResponse = await httpClient.post(`api/logs/log/${selectedRowKey}/track`, formData, formDataConfig); + const uploadUrl = uploadResponse.data.url; + const log = await getLog(); + const tracks: string[] = JSON.parse(log.tracks!); + + tracks.push(uploadUrl) + log.tracks = JSON.stringify(tracks); + await httpClient.put(`api/logs/log/${selectedRowKey}`, log, config); + + const updatedLog = await getLog(); + + setTracks(JSON.parse(updatedLog.tracks!)) + } catch (error) { + console.log(error) + } finally { + setIsLoading(false); + } + } + + const onCancel = () => { + onOpenClose(FormMode.CANCEL) + } + + const onDelete = async (fileName: string, index: number) => { + try { + const config = await getConfig(); + + await httpClient.delete(`api/logs/log/${selectedRowKey}/track?fileName=${fileName}`, config); + + const log = await getLog(); + const tracks: string[] = JSON.parse(log.tracks!); + + tracks.splice(index, 1); + log.tracks = JSON.stringify(tracks); + await httpClient.put(`api/logs/log/${selectedRowKey}`, log, config); + + const updatedLog = await getLog(); + + setTracks(JSON.parse(updatedLog.tracks!)) + } catch (error) { + console.log(error); + } + } + + useEffect(() => { + const updateTracks = async () => { + const log = await getLog(); + + setTracks(JSON.parse(log.tracks!)); + } + + updateTracks(); + }, []) + + return ( + + + + {`${mode.toString().toLowerCase().charAt(0).toUpperCase() + mode.toString().slice(1).toLowerCase()} Tracks`} + + + + + + + {tracks.length > 0 && tracks.map((track, index) => { + const trackSplit = track.split('/') + const filename = trackSplit[trackSplit.length - 1]; + + return ( + <> + + + + + onDelete(filename, index)}> + + + ) + }) + } + + + {mode.toString() !== FormMode.VIEW && ( + + )} + + + + ) +} + +export default LogTracks; \ No newline at end of file diff --git a/app/src/components/logTracks/LogTracksProps.interface.ts b/app/src/components/logTracks/LogTracksProps.interface.ts new file mode 100644 index 0000000..b7b8483 --- /dev/null +++ b/app/src/components/logTracks/LogTracksProps.interface.ts @@ -0,0 +1,8 @@ +import { FormMode } from "../../enums/formMode"; + +export interface LogTracksProps { + isDrawerOpen: boolean; + mode: FormMode; + onOpenClose: (mode: FormMode) => void; + selectedRowKey: string | undefined; +} \ No newline at end of file diff --git a/app/src/components/logbook/ILogbookEntry.ts b/app/src/components/logbook/ILogbookEntry.ts index 428f57f..53cca9c 100644 --- a/app/src/components/logbook/ILogbookEntry.ts +++ b/app/src/components/logbook/ILogbookEntry.ts @@ -1,3 +1,5 @@ +import { ColumnDef } from "@noahspan/noahspan-components"; + export interface ILogbookEntry { partitionKey: string; rowKey: string; @@ -25,5 +27,6 @@ export interface ILogbookEntry { night: number | null; solo: number | null; pilotInCommand: number | null; + tracks: string | undefined; notes: string; } diff --git a/app/src/components/logbook/ILogbookState.ts b/app/src/components/logbook/ILogbookState.ts index 31844b2..18ae283 100644 --- a/app/src/components/logbook/ILogbookState.ts +++ b/app/src/components/logbook/ILogbookState.ts @@ -1,14 +1,18 @@ +import { ColumnDef } from '@noahspan/noahspan-components'; import { FormMode } from '../../enums/formMode'; import { Alert } from '../../interfaces/Alert.interface'; import { ILogbookEntry } from './ILogbookEntry'; export interface ILogbookState { alert: Alert | undefined; + columns: ColumnDef[]; entries: ILogbookEntry[]; formMode: FormMode; isConfirmDialogLoading: boolean; isConfirmDialogOpen: boolean; isFormOpen: boolean; isLoading: boolean; + isTracksOpen: boolean; selectedEntryId: string | undefined; + tracksMode: FormMode; } diff --git a/app/src/components/logbook/Logbook.tsx b/app/src/components/logbook/Logbook.tsx index 0f6c848..03e7d03 100644 --- a/app/src/components/logbook/Logbook.tsx +++ b/app/src/components/logbook/Logbook.tsx @@ -7,6 +7,7 @@ import { ColumnDef, Grid, Icon, + IconButton, IconName, Spinner, Table, @@ -20,10 +21,12 @@ import { AxiosError, AxiosInstance, AxiosResponse } from 'axios'; import { useAccessToken } from '../../hooks/accessToken/UseAcessToken'; import { useIsAuthenticated } from '@azure/msal-react'; import { FormMode } from '../../enums/formMode'; +import { authColumns, unauthColumns } from './columns'; import ActionMenu from '../actionMenu/ActionMenu'; import ConfirmationDialog from '../confirmationDialog/ConfirmationDialog'; import { ILogbookEntry } from './ILogbookEntry'; import LogbookCard from '../logbookCard/LogbookCard'; +import LogTracks from '../logTracks/LogTracks'; const Logbook: React.FC = () => { const [state, dispatch] = useReducer(reducer, initialState); @@ -31,12 +34,41 @@ const Logbook: React.FC = () => { const isAuthenticated = useIsAuthenticated(); const { getAccessToken } = useAccessToken(); const isMedium = useMediaQuery(theme.breakpoints.up('md')); + const actionsColumn: ColumnDef = { + header: 'Actions', + meta: { + align: 'center', + headerAlign: 'center' + }, + cell: (info: any) => ( + + ) + } + const tracksColumn: ColumnDef = { + accessorKey: 'tracks', + header: 'Tracks', + cell: (info: any) => { + if (info.row.original.tracks && info.row.original.tracks.length > 0) { + return ( + onOpenCloseTracks(FormMode.VIEW, info.row.original.rowKey)}> + ) + } + } + } const getLogbookEntries = async () => { try { dispatch({ type: 'SET_IS_LOADING', payload: true }); - const response: AxiosResponse = await httpClient.get(`api/logs`); + const config = isAuthenticated + ? { headers: { Authorization: await getAccessToken() } } + : {}; + const response: AxiosResponse = await httpClient.get(`api/logs`, config); const entries: ILogbookEntry[] = response.data; entries.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime()) @@ -91,6 +123,34 @@ const Logbook: React.FC = () => { } }; + const onOpenCloseTracks = (mode: FormMode, rowKey?: string) => { + switch(mode) { + case FormMode.EDIT: + case FormMode.VIEW: + dispatch({ + type: 'SET_OPEN_CLOSE_TRACKS', + payload: { + tracksMode: mode, + isTracksOpen: true, + selectedRowKey: rowKey + } + }) + + break; + case FormMode.CANCEL: + dispatch({ + type: 'SET_OPEN_CLOSE_TRACKS', + payload: { + tracksMode: mode, + isTracksOpen: false, + selectedRowKey: undefined + } + }) + + break; + } + } + const onDeleteEntry = (entryId: string) => { dispatch({ type: 'SET_DELETE', @@ -133,292 +193,36 @@ const Logbook: React.FC = () => { }); }; - const unauthColumns: ColumnDef[] = [ - { - accessorKey: 'pilotName', - header: 'Pilot', - }, - { - accessorKey: 'date', - header: 'Date' - }, - { - accessorKey: 'aircraftMakeModel', - header: 'Aircraft Make & Model' - }, - { - id: 'route', - header: 'Route of Flight', - meta: { - headerAlign: 'center' - }, - columns: [ - { - accessorKey: 'routeFrom', - header: 'From' - }, - { - accessorKey: 'routeTo', - header: 'To' - } - ] - }, - { - accessorKey: 'durationOfFlight', - header: 'Duration Of Flight', - meta: { - align: 'right', - headerAlign: 'right' - }, - cell: (info: any) => - info.getValue() ? parseFloat(info.getValue()).toFixed(1) : '' - }, - { - accessorKey: 'notes', - header: 'Notes' - }, - { - header: 'Actions', - meta: { - align: 'center', - headerAlign: 'center' - }, - cell: (info: any) => ( - - ) - } - ] + useEffect(() => { + let newColumns: ColumnDef[]; - const authColumns: ColumnDef[] = [ - { - accessorKey: 'pilotName', - header: 'Pilot', - }, - { - accessorKey: 'date', - header: 'Date' - }, - { - accessorKey: 'aircraftMakeModel', - header: 'Aircraft Make & Model' - }, - { - accessorKey: 'aircraftIdentity', - header: 'Aircraft Identity', - }, - { - id: 'route', - header: 'Route of Flight', - meta: { - headerAlign: 'center' - }, - columns: [ - { - accessorKey: 'routeFrom', - header: 'From' - }, - { - accessorKey: 'routeTo', - header: 'To' - } - ] - }, - { - accessorKey: 'durationOfFlight', - header: 'Duration Of Flight', - meta: { - align: 'right', - headerAlign: 'right' - }, - cell: (info: any) => - info.getValue() ? parseFloat(info.getValue()).toFixed(1) : '' - }, - { - accessorKey: 'singleEngineLand', - header: 'Single Engine Land', - meta: { - align: 'right', - headerAlign: 'right' - }, - cell: (info: any) => - info.getValue() ? parseFloat(info.getValue()).toFixed(1) : '' - }, - { - id: 'landings', - header: 'Landings', - meta: { - headerAlign: 'center' - }, - columns: [ - { - accessorKey: 'landingsDay', - header: 'Day', - meta: { - align: 'right', - headerAlign: 'right' - } - }, - { - accessorKey: 'landingsNight', - header: 'Night', - meta: { - align: 'right', - headerAlign: 'right' - } - } - ] - }, - { - id: 'instrument', - header: 'Instrument', - meta: { - headerAlign: 'center' - }, - columns: [ - { - accessorKey: 'instrumentActual', - header: 'Actual', - meta: { - align: 'right', - headerAlign: 'right' - }, - cell: (info: any) => - info.getValue() ? parseFloat(info.getValue()).toFixed(1) : '' - }, - { - accessorKey: 'instrumentSimulated', - header: 'Simulated', - meta: { - align: 'right', - headerAlign: 'right' - }, - cell: (info: any) => - info.getValue() ? parseFloat(info.getValue()).toFixed(1) : '' - }, - { - accessorKey: 'instrumentApproaches', - header: 'Approaches', - meta: { - align: 'right', - headerAlign: 'right' - } - }, - { - accessorKey: 'instrumentHolds', - header: 'Holds', - meta: { - align: 'right', - headerAlign: 'right' - } - }, - { - accessorKey: 'instrumentNavTrack', - header: 'Nav/Track', - meta: { - align: 'right', - headerAlign: 'right' - } - } - ] - }, - { - id: 'experienceTraining', - header: 'Type of pilot experience or training', - meta: { - headerAlign: 'center' - }, - columns: [ - { - accessorKey: 'groundTrainingReceived', - header: 'Ground Training Received', - meta: { - align: 'right', - headerAlign: 'right' - }, - cell: (info: any) => - info.getValue() ? parseFloat(info.getValue()).toFixed(1) : '' - }, - { - accessorKey: 'flightTrainingReceived', - header: 'Flight Training Received', - meta: { - align: 'right', - headerAlign: 'right' - }, - cell: (info: any) => - info.getValue() ? parseFloat(info.getValue()).toFixed(1) : '' - }, - { - accessorKey: 'crossCountry', - header: 'Cross Country', - meta: { - align: 'right', - headerAlign: 'right' - }, - cell: (info: any) => - info.getValue() ? parseFloat(info.getValue()).toFixed(1) : '' - }, - { - accessorKey: 'night', - header: 'Night', - meta: { - align: 'right', - headerAlign: 'right' - }, - cell: (info: any) => - info.getValue() ? parseFloat(info.getValue()).toFixed(1) : '' - }, - { - accessorKey: 'solo', - header: 'Solo', - meta: { - align: 'right', - headerAlign: 'right' - }, - cell: (info: any) => - info.getValue() ? parseFloat(info.getValue()).toFixed(1) : '' - }, - { - accessorKey: 'pilotInCommand', - header: 'Pilot In Command', - meta: { - align: 'right', - headerAlign: 'right' - }, - cell: (info: any) => - info.getValue() ? parseFloat(info.getValue()).toFixed(1) : '' - } - ] - }, - { - accessorKey: 'notes', - header: 'Notes' - }, - { - header: 'Actions', - meta: { - align: 'center', - headerAlign: 'center' - }, - cell: (info: any) => ( - - ) + if (isAuthenticated) { + 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 }) + }, [isAuthenticated]) useEffect(() => { if (!state.isFormOpen) { getLogbookEntries(); } - }, [state.isFormOpen]); + }, [state.isFormOpen, state.isTracksOpen]); return ( @@ -453,8 +257,8 @@ const Logbook: React.FC = () => { )} {!state.isLoading && ( - {isMedium && state.entries.length > 0 && ( - + {isMedium && state.columns && state.columns.length > 0 && state.entries.length > 0 && ( +
)} {!isMedium && state.entries.length > 0 && @@ -490,6 +294,14 @@ const Logbook: React.FC = () => { title="Confirm Delete" /> )} + {state.isTracksOpen && + onOpenCloseTracks(mode)} + selectedRowKey={state.selectedEntryId} + /> + } ); }; diff --git a/app/src/components/logbook/columns.tsx b/app/src/components/logbook/columns.tsx new file mode 100644 index 0000000..def4ad4 --- /dev/null +++ b/app/src/components/logbook/columns.tsx @@ -0,0 +1,252 @@ +import { + ColumnDef, + Icon, + IconButton, + IconName +} from '@noahspan/noahspan-components'; +import { ILogbookEntry } from './ILogbookEntry'; + +const pilotName: ColumnDef = { + id: 'pilotName', + accessorKey: 'pilotName', + header: 'Pilot' +} +const date: ColumnDef = { + id: 'date', + accessorKey: 'date', + header: 'Date' +} +const aircraftMakeModel: ColumnDef = { + id: 'aircraftMakeModel', + accessorKey: 'aircraftMakeModel', + header: 'Aircraft Make & Model' +} +const route: ColumnDef = { + 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 = { + id: 'durationOfFlight', + accessorKey: 'durationOfFlight', + header: 'Duration Of Flight', + meta: { + align: 'right', + headerAlign: 'right' + }, + cell: (info: any) => + info.getValue() ? parseFloat(info.getValue()).toFixed(1) : '' +} +const notes: ColumnDef = { + id: 'notes', + accessorKey: 'notes', + header: 'Notes' +} + +export const unauthColumns: ColumnDef[] = [ + pilotName, + date, + aircraftMakeModel, + route, + durationOfFlight, + notes +] + +export const authColumns: ColumnDef[] = [ + 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: any) => + info.getValue() ? parseFloat(info.getValue()).toFixed(1) : '' + }, + { + id: 'landings', + header: 'Landings', + meta: { + headerAlign: 'center' + }, + columns: [ + { + id: 'landingsDay', + accessorKey: 'landingsDay', + header: 'Day', + meta: { + align: 'right', + headerAlign: 'right' + } + }, + { + id: 'landingsNight', + accessorKey: 'landingsNight', + header: 'Night', + meta: { + align: 'right', + headerAlign: 'right' + } + } + ] + }, + { + id: 'instrument', + header: 'Instrument', + meta: { + headerAlign: 'center' + }, + columns: [ + { + id: 'instrumentActual', + accessorKey: 'instrumentActual', + header: 'Actual', + meta: { + align: 'right', + headerAlign: 'right' + }, + cell: (info: any) => + info.getValue() ? parseFloat(info.getValue()).toFixed(1) : '' + }, + { + id: 'instrumentSimulated', + accessorKey: 'instrumentSimulated', + header: 'Simulated', + meta: { + align: 'right', + headerAlign: 'right' + }, + cell: (info: any) => + info.getValue() ? parseFloat(info.getValue()).toFixed(1) : '' + }, + { + id: 'instrumentApproaches', + accessorKey: 'instrumentApproaches', + header: 'Approaches', + meta: { + align: 'right', + headerAlign: 'right' + } + }, + { + id: 'instrumentHolds', + accessorKey: 'instrumentHolds', + header: 'Holds', + meta: { + align: 'right', + headerAlign: 'right' + } + }, + { + id: 'instrumentNavTrack', + accessorKey: 'instrumentNavTrack', + header: 'Nav/Track', + 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', + meta: { + align: 'right', + headerAlign: 'right' + }, + cell: (info: any) => + info.getValue() ? parseFloat(info.getValue()).toFixed(1) : '' + }, + { + id: 'flightTrainingReceived', + accessorKey: 'flightTrainingReceived', + header: 'Flight Training Received', + meta: { + align: 'right', + headerAlign: 'right' + }, + cell: (info: any) => + info.getValue() ? parseFloat(info.getValue()).toFixed(1) : '' + }, + { + id: 'crossCountry', + accessorKey: 'crossCountry', + header: 'Cross Country', + meta: { + align: 'right', + headerAlign: 'right' + }, + cell: (info: any) => + info.getValue() ? parseFloat(info.getValue()).toFixed(1) : '' + }, + { + id: 'night', + accessorKey: 'night', + header: 'Night', + meta: { + align: 'right', + headerAlign: 'right' + }, + cell: (info: any) => + info.getValue() ? parseFloat(info.getValue()).toFixed(1) : '' + }, + { + id: 'solo', + accessorKey: 'solo', + header: 'Solo', + meta: { + align: 'right', + headerAlign: 'right' + }, + cell: (info: any) => + info.getValue() ? parseFloat(info.getValue()).toFixed(1) : '' + }, + { + id: 'pilotInCommand', + accessorKey: 'pilotInCommand', + header: 'Pilot In Command', + meta: { + align: 'right', + headerAlign: 'right' + }, + cell: (info: any) => + info.getValue() ? parseFloat(info.getValue()).toFixed(1) : '' + } + ] + }, + notes +] \ No newline at end of file diff --git a/app/src/components/logbook/reducer.ts b/app/src/components/logbook/reducer.ts index caff5ca..ee58a4e 100644 --- a/app/src/components/logbook/reducer.ts +++ b/app/src/components/logbook/reducer.ts @@ -1,9 +1,11 @@ +import { ColumnDef } from '@noahspan/noahspan-components'; import { FormMode } from '../../enums/formMode'; import { Alert } from '../../interfaces/Alert.interface'; import { ILogbookEntry } from './ILogbookEntry'; import { ILogbookState } from './ILogbookState'; type Action = + | { type: 'SET_COLUMNS'; payload: ColumnDef[] } | { type: 'SET_DELETE'; payload: { @@ -23,17 +25,21 @@ type Action = selectedEntryId: string | undefined; isFormOpen: boolean; }; - }; + } + | { type: 'SET_OPEN_CLOSE_TRACKS'; payload: { tracksMode: FormMode, selectedRowKey: string | undefined, isTracksOpen: boolean; }}; export const initialState: ILogbookState = { alert: undefined, + columns: [], entries: [], formMode: FormMode.CANCEL, isConfirmDialogLoading: false, isConfirmDialogOpen: false, isFormOpen: false, isLoading: false, - selectedEntryId: undefined + isTracksOpen: false, + selectedEntryId: undefined, + tracksMode: FormMode.CANCEL }; export const reducer = ( @@ -41,6 +47,12 @@ export const reducer = ( action: Action ): ILogbookState => { switch (action.type) { + case 'SET_COLUMNS': { + return { + ...state, + columns: action.payload + } + } case 'SET_DELETE': { return { ...state, @@ -86,6 +98,14 @@ export const reducer = ( selectedEntryId: action.payload.selectedEntryId }; } + case 'SET_OPEN_CLOSE_TRACKS': { + return { + ...state, + tracksMode: action.payload.tracksMode, + isTracksOpen: action.payload.isTracksOpen, + selectedEntryId: action.payload.selectedRowKey + } + } default: { return state; } diff --git a/docker-compose.yaml b/docker-compose.yaml index 83d51f6..526c0e9 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -8,7 +8,7 @@ services: - '10000:10000' - '10001:10001' - '10002:10002' - command: 'azurite --blobHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0 --loose' + command: 'azurite --blobHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0 --loose --skipApiVersionCheck' volumes: - ./azurite-flying:/data diff --git a/infrastructure/config/main.tf b/infrastructure/config/main.tf index 9ad854a..6aef296 100644 --- a/infrastructure/config/main.tf +++ b/infrastructure/config/main.tf @@ -59,6 +59,11 @@ locals { prod = "noahspanflyingprod" } + storage_containers = { + test = ["tracks"] + prod = ["tracks"] + } + storage_tables = { test = ["logs", "pilots"] prod = ["logs", "pilots"] diff --git a/infrastructure/config/outputs.tf b/infrastructure/config/outputs.tf index bda1b15..7f897b4 100644 --- a/infrastructure/config/outputs.tf +++ b/infrastructure/config/outputs.tf @@ -46,6 +46,10 @@ output "storage_account_name" { value = local.storage_account_name[var.environment] } +output "storage_containers" { + value = local.storage_containers[var.environment] +} + output "storage_tables" { value = local.storage_tables[var.environment] } \ No newline at end of file diff --git a/infrastructure/main.tf b/infrastructure/main.tf index 24c41e8..a4c92fb 100644 --- a/infrastructure/main.tf +++ b/infrastructure/main.tf @@ -2,6 +2,7 @@ module "storage" { source = "github.com/noahspannbauer/noahspan-terraform/modules/storage" resource_group_name = var.RESOURCE_GROUP_NAME storage_account_name = module.environment.storage_account_name + storage_containers = module.environment.storage_containers storage_tables = module.environment.storage_tables } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8a9ccca..dda9655 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -48,6 +48,9 @@ importers: api: dependencies: + '@azure/storage-blob': + specifier: ^12.27.0 + version: 12.27.0 '@microsoft/microsoft-graph-client': specifier: ^3.0.7 version: 3.0.7(@azure/identity@4.6.0) @@ -78,6 +81,9 @@ importers: '@schematics/angular': specifier: ^17.3.7 version: 17.3.11(chokidar@3.6.0) + '@types/multer': + specifier: ^1.4.12 + version: 1.4.12 dotenv: specifier: ^16.4.7 version: 16.4.7 @@ -90,6 +96,9 @@ importers: uuid: specifier: ^10.0.0 version: 10.0.0 + uuidv4: + specifier: ^6.2.13 + version: 6.2.13 devDependencies: '@microsoft/microsoft-graph-types': specifier: ^2.40.0 @@ -149,8 +158,8 @@ importers: specifier: ^3.0.1 version: 3.0.1(@azure/msal-browser@4.0.1)(react@18.3.1) '@noahspan/noahspan-components': - specifier: ^1.5.1 - version: 1.5.1(@mui/system@6.4.6(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(less@4.2.2)(moment@2.30.1)(postcss@8.5.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.3(prettier@3.2.5))(typescript@5.7.3)(webpack@5.97.1(esbuild@0.18.20)) + specifier: ^1.6.0 + version: 1.6.0(@mui/system@6.4.6(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(less@4.2.2)(moment@2.30.1)(postcss@8.5.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.3(prettier@3.2.5))(typescript@5.7.3)(webpack@5.97.1(esbuild@0.18.20)) axios: specifier: ^1.7.2 version: 1.7.9 @@ -305,6 +314,10 @@ packages: '@azure/msal-browser': ^4.0.1 react: ^16.8.0 || ^17 || ^18 + '@azure/storage-blob@12.27.0': + resolution: {integrity: sha512-IQjj9RIzAKatmNca3D6bT0qJ+Pkox1WZGOg2esJF2YLHb45pQKOwGPIAV+w3rfgkj7zV3RMxpn/c6iftzSOZJQ==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.26.2': resolution: {integrity: sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==} engines: {node: '>=6.9.0'} @@ -1564,8 +1577,8 @@ packages: '@nestjs/common': ^9.0.0 || ^10.0.0 '@nestjs/core': ^9.0.0 || ^10.0.0 - '@noahspan/noahspan-components@1.5.1': - resolution: {integrity: sha512-GDowrEyxP/uZ1esrJVXWNKCzmG8s/OHra7JVudBKfQhDzxWE9o+RDqIHhX78YwbMsE1cu4BEvNAM8oa6jTaAuQ==} + '@noahspan/noahspan-components@1.6.0': + resolution: {integrity: sha512-CbJjm7zhhK3+x2jsvHbNXzFVseVf3hAATGsgPC8pfr175sol6an28OrmmnaRzQBsN4+61WcJvzYXM/ftQphHLw==} engines: {node: '>=18.0.0'} peerDependencies: react: ^18.2.0 @@ -2381,6 +2394,9 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} + '@types/multer@1.4.12': + resolution: {integrity: sha512-pQ2hoqvXiJt2FP9WQVLPRO+AmiIm/ZYkavPlIQnx282u4ZrVdztx0pkh3jjpQt0Kz+YI0YhSG264y08UJKoUQg==} + '@types/node-fetch@2.6.12': resolution: {integrity: sha512-8nneRWKCg3rMtF69nLQJnOYUcbafYeFSjqkw3jCRLsqkWFlHaoQrr5mXmofFGOx3DKn7UfmBMyov8ySvLRVldA==} @@ -2439,6 +2455,9 @@ packages: '@types/supertest@6.0.2': resolution: {integrity: sha512-137ypx2lk/wTQbW6An6safu9hXmajAifU/s7szAHLN/FeIm5w7yR0Wkl9fdJMRSHwOn4HLAI0DaB2TOORuhPDg==} + '@types/uuid@8.3.4': + resolution: {integrity: sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==} + '@types/yargs-parser@21.0.3': resolution: {integrity: sha512-I4q9QU9MQv4oEOz4tAHJtNz1cwuLxn2F3xcc2iV5WdqLPpUnj30aUuxt1mAxYTG+oe8CZMV/+6rU4S4gRDzqtQ==} @@ -5660,6 +5679,10 @@ packages: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} hasBin: true + uuidv4@6.2.13: + resolution: {integrity: sha512-AXyzMjazYB3ovL3q051VLH06Ixj//Knx7QnUSi1T//Ie3io6CpsPu9nVMOx5MoLWh6xV0B9J0hIaxungxXUbPQ==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. + v8-compile-cache-lib@3.0.1: resolution: {integrity: sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==} @@ -6034,6 +6057,24 @@ snapshots: '@azure/msal-browser': 4.0.1 react: 18.3.1 + '@azure/storage-blob@12.27.0': + dependencies: + '@azure/abort-controller': 2.1.2 + '@azure/core-auth': 1.9.0 + '@azure/core-client': 1.9.2 + '@azure/core-http-compat': 2.1.2 + '@azure/core-lro': 2.7.2 + '@azure/core-paging': 1.6.2 + '@azure/core-rest-pipeline': 1.18.2 + '@azure/core-tracing': 1.2.0 + '@azure/core-util': 1.11.0 + '@azure/core-xml': 1.4.4 + '@azure/logger': 1.1.4 + events: 3.3.0 + tslib: 2.8.1 + transitivePeerDependencies: + - supports-color + '@babel/code-frame@7.26.2': dependencies: '@babel/helper-validator-identifier': 7.25.9 @@ -7289,7 +7330,7 @@ snapshots: - stream-browserify - supports-color - '@noahspan/noahspan-components@1.5.1(@mui/system@6.4.6(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(less@4.2.2)(moment@2.30.1)(postcss@8.5.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.3(prettier@3.2.5))(typescript@5.7.3)(webpack@5.97.1(esbuild@0.18.20))': + '@noahspan/noahspan-components@1.6.0(@mui/system@6.4.6(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@emotion/styled@11.14.0(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1))(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(less@4.2.2)(moment@2.30.1)(postcss@8.5.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(storybook@8.6.3(prettier@3.2.5))(typescript@5.7.3)(webpack@5.97.1(esbuild@0.18.20))': dependencies: '@emotion/react': 11.14.0(@types/react@18.3.18)(react@18.3.1) '@emotion/styled': 11.14.0(@emotion/react@11.14.0(@types/react@18.3.18)(react@18.3.1))(@types/react@18.3.18)(react@18.3.1) @@ -8294,6 +8335,10 @@ snapshots: '@types/ms@2.1.0': {} + '@types/multer@1.4.12': + dependencies: + '@types/express': 4.17.21 + '@types/node-fetch@2.6.12': dependencies: '@types/node': 20.17.14 @@ -8364,6 +8409,8 @@ snapshots: '@types/methods': 1.1.4 '@types/superagent': 8.1.9 + '@types/uuid@8.3.4': {} + '@types/yargs-parser@21.0.3': {} '@types/yargs@17.0.33': @@ -12050,6 +12097,11 @@ snapshots: uuid@9.0.1: {} + uuidv4@6.2.13: + dependencies: + '@types/uuid': 8.3.4 + uuid: 8.3.2 + v8-compile-cache-lib@3.0.1: {} v8-to-istanbul@9.3.0: