Compare commits

..

17 Commits

Author SHA1 Message Date
a045942630 Merge branch '96-switch-from-azure-table-storage-to-sqlite' of https://github.com/noahspannbauer/noahspan-flying into 96-switch-from-azure-table-storage-to-sqlite
Please enter a commit message to explain why this merge is necessary,
 especially if it merges an updated upstream into a topic branch.

 Lines starting with '#' will be ignored, and an empty message aborts
 the commit.
2025-11-23 11:14:14 -06:00
2236132340 updating infrastructure 2025-11-23 11:14:07 -06:00
6baff58a36 Merge branch 'main' into 96-switch-from-azure-table-storage-to-sqlite 2025-11-23 11:04:17 -06:00
b5093045e3 updating infrastructure 2025-11-23 11:03:39 -06:00
b85b58a324 Merge branch '96-switch-from-azure-table-storage-to-sqlite' of https://github.com/noahspannbauer/noahspan-flying into 96-switch-from-azure-table-storage-to-sqlite
Please enter a commit message to explain why this merge is necessary,
 especially if it merges an updated upstream into a topic branch.

 Lines starting with '#' will be ignored, and an empty message aborts
 the commit.
2025-11-23 11:00:01 -06:00
bffbf35a43 updating infrastructure 2025-11-23 10:59:53 -06:00
73f08c970b Merge branch 'main' into 96-switch-from-azure-table-storage-to-sqlite 2025-11-23 10:48:39 -06:00
662dcc0a64 updating infrastructure 2025-11-23 10:47:54 -06:00
3ca72b2c7d updating infrastructure 2025-11-23 10:43:27 -06:00
0ea7e48b90 updating infrastructure 2025-11-23 10:43:19 -06:00
23b0361718 updating infrastructure 2025-11-23 10:24:57 -06:00
2a596b29f7 updating terraform 2025-11-20 19:08:24 -06:00
280b222d9a migrating to sqlite 2025-10-24 08:31:13 -05:00
05b17878d7 switching to sqlite 2025-10-02 12:00:24 -05:00
ab304a469b switching to sqlite 2025-08-20 21:04:32 -05:00
e1605992b0 switching to sqlite 2025-08-07 14:59:06 -05:00
25886c8498 adding typeorm to api 2025-07-24 21:11:13 -05:00
108 changed files with 6473 additions and 4312 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@@ -1,44 +0,0 @@
name: Deploy
on:
workflow_call:
inputs:
app_name:
required: true
type: string
environment_name:
required: true
type: string
version_number:
required: true
type: string
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment_name }}
steps:
- name: Get Secrets from Infisical
uses: Infisical/secrets-action@v1.0.16
with:
domain: ${{ secrets.INFISICAL_DOMAIN }}
client-id: ${{ secrets.INFISICAL_CLIENT_ID }}
client-secret: ${{ secrets.INFISICAL_CLIENT_SECRET }}
project-slug: ${{ secrets.INFISICAL_PROJECT_SLUG }}
env-slug: ${{ inputs.environment_name }}
secret-path: /flying
- name: Install Azure CLI
run: |
curl -sL https://aka.ms/InstallAzureCLIDeb | sudo bash
- name: Log in to Azure
run: |
az login --service-principal --username ${{ env.AZURE_CLIENT_ID }} --password ${{ env.AZURE_CLIENT_SECRET }} --tenant ${{ env.AZURE_TENANT_ID }}
- name: Update Container App
run: |
az containerapp update --name ${{ inputs.app_name }}-${{ inputs.environment_name }} --container-name ${{ inputs.app_name }} --resource-group ${{ env.RESOURCE_GROUP }} --image docker.io/noahspan/${{ inputs.app_name }}:${{ inputs.version_number }}

View File

@@ -1,29 +0,0 @@
name: Pull Request
on:
pull_request:
jobs:
changes:
uses: ./.gitea/workflows/changes.yaml
build:
if: ${{ needs.changes.outputs.api == 'true' || needs.changes.outputs.client == 'true'}}
name: build-and-test
needs:
- changes
uses: ./.gitea/workflows/build_and_test.yaml
with:
environment_name: test
version_number: ${{ github.run_id }}
secrets: inherit
terraform:
if: ${{ needs.changes.outputs.infrastructure == 'true' }}
name: terraform-plan
needs:
- changes
uses: ./.gitea/workflows/infrastructure.yaml
with:
environment_name: test
secrets: inherit

View File

@@ -1,45 +0,0 @@
name: 'Terraform'
on:
workflow_dispatch:
inputs:
environment_name:
type: string
jobs:
terraform:
runs-on: ubuntu-latest
environment:
steps:
- name: Checkout
uses: actions/checkout@v4
with:
sparse-checkout: |
infrastructure
- name: Get Secrets from Infisical
uses: Infisical/secrets-action@v1.0.16
with:
domain: ${{ secrets.INFISICAL_DOMAIN }}
client-id: ${{ secrets.INFISICAL_CLIENT_ID }}
client-secret: ${{ secrets.INFISICAL_CLIENT_SECRET }}
project-slug: ${{ secrets.INFISICAL_PROJECT_SLUG }}
env-slug: ${{ inputs.environment_name }}
secret-path: /flying/terraform
- name: Setup Terraform
uses: hashicorp/setup-terraform@v4
- name: Terraform init
run: |
terraform -chdir=./infrastructure init -input=false
- name: Terraform plan
if: ${{ github.event_name == 'pull_request' }}
run: |
terraform -chdir=./infrastructure plan -no-color -input=false
- name: Terraform apply
if: ${{ github.event_name != 'pull_request' }}
run: |
terraform -chdir=./infrastructure apply -no-color -input=false

View File

@@ -10,27 +10,17 @@ on:
type: string
jobs:
build-and-test:
build:
runs-on: ubuntu-latest
environment: ${{ inputs.environment_name }}
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v5
with:
sparse-checkout: |
api
client
- name: Get Secrets from Infisical
uses: Infisical/secrets-action@v1.0.16
with:
domain: ${{ secrets.INFISICAL_DOMAIN }}
client-id: ${{ secrets.INFISICAL_CLIENT_ID }}
client-secret: ${{ secrets.INFISICAL_CLIENT_SECRET }}
project-slug: ${{ secrets.INFISICAL_PROJECT_SLUG }}
env-slug: ${{ inputs.environment_name }}
secret-path: /flying
- name: Install Nest CLI
run: |
npm install -g @nestjs/cli
@@ -38,7 +28,7 @@ jobs:
- name: 'Setup Node'
uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
node-version: ${{ vars.NODE_VERSION }}
- name: Install dependencies
run: |
@@ -48,17 +38,7 @@ jobs:
run: |
npm run build -w api
- name: Test API
run: |
npm run test -w api
- name: Build Client
env:
VITE_API_URL: ${{ env.VITE_API_URL }}
VITE_BASE_URL: ${{ env.VITE_BASE_URL }}
VITE_CLIENT_ID: ${{ env.VITE_CLIENT_ID }}
VITE_ISSUER_URI: ${{ env.VITE_ISSUER_URI }}
VITE_TENANT_ID: ${{ env.VITE_TENANT_ID }}
run: |
npm run build -w client
@@ -66,8 +46,8 @@ jobs:
if: ${{ github.event_name != 'pull_request' }}
uses: docker/login-action@v3
with:
username: ${{ env.DOCKERHUB_USERNAME }}
password: ${{ env.DOCKERHUB_TOKEN }}
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Setup Docker Buildx
if: ${{ github.event_name != 'pull_request' }}

View File

@@ -7,9 +7,6 @@ on:
client:
value: ${{ jobs.changes.outputs.client }}
permissions:
contents: read
jobs:
changes:
name: filter
@@ -18,13 +15,7 @@ jobs:
api: ${{ steps.filter.outputs.api }}
client: ${{ steps.filter.outputs.client }}
steps:
- name: 'Setup Node'
uses: actions/setup-node@v6
with:
node-version: '22.x'
- name: Checkout
uses: actions/checkout@v4
- uses: actions/checkout@v3
- uses: dorny/paths-filter@v3
id: filter
@@ -33,6 +24,4 @@ jobs:
api:
- 'api/**'
client:
- 'client/**'
infrastructure:
- 'infrastructure/**'
- 'client/**'

35
.github/workflows/deploy.yaml vendored Normal file
View File

@@ -0,0 +1,35 @@
name: Deploy
on:
workflow_call:
inputs:
app_name:
required: true
type: string
environment_name:
required: true
type: string
version_number:
required: true
type: string
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment_name }}
steps:
- name: Log in to Azure
uses: azure/login@v2
with:
client-id: ${{ vars.VITE_CLIENT_ID }}
subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }}
tenant-id: ${{ vars.VITE_TENANT_ID }}
- name: Azure CLI script
uses: azure/cli@v2
with:
azcliversion: latest
inlineScript: |
az containerapp update --name ${{ inputs.app_name }}-${{ inputs.environment_name }} --resource-group noahspan-flying --image docker.io/noahspan/${{ inputs.app_name }}:${{ inputs.version_number }}

View File

@@ -6,14 +6,14 @@ on:
jobs:
changes:
uses: ./.gitea/workflows/changes.yaml
uses: ./.github/workflows/changes.yaml
build-and-test:
if: ${{ needs.changes.outputs.api == 'true' || needs.changes.outputs.client == 'true' }}
name: build-and-test
build:
if: ${{ needs.changes.outputs.app == 'true' || needs.changes.outputs.client == 'true' }}
name: build
needs:
- changes
uses: ./.gitea/workflows/build_and_test.yaml
uses: ./.github/workflows/build.yaml
with:
environment_name: test
version_number: ${{ github.run_id }}
@@ -23,8 +23,8 @@ jobs:
name: deploy
needs:
- changes
- build-and-test
uses: ./.gitea/workflows/deploy.yaml
- build
uses: ./.github/workflows/deploy.yaml
with:
app_name: flying
environment_name: test

19
.github/workflows/pull_request.yaml vendored Normal file
View File

@@ -0,0 +1,19 @@
name: Pull Request
on:
pull_request:
jobs:
changes:
uses: ./.github/workflows/changes.yaml
build:
if: ${{ needs.changes.outputs.app == 'true' || needs.changes.outputs.client == 'true'}}
name: build
needs:
- changes
uses: ./.github/workflows/build.yaml
with:
environment_name: pull_request
version_number: ${{ github.run_id }}
secrets: inherit

View File

@@ -2,12 +2,12 @@ name: Tag
on:
push:
tags:
- '**'
- 'v[0-9]+.[0-9]+.[0-9]+'
jobs:
build-and-test:
name: build-and-test
uses: ./.gitea/workflows/build_and_test.yaml
build:
name: build
uses: ./.github/workflows/build.yaml
with:
environment_name: prod
version_number: ${{ github.ref_name }}
@@ -16,8 +16,8 @@ jobs:
deploy:
name: deploy
needs:
- build-and-test
uses: ./.gitea/workflows/deploy.yaml
- build
uses: ./.github/workflows/deploy.yaml
with:
app_name: flying
environment_name: prod

View File

@@ -1,4 +1,4 @@
FROM node:22
FROM --platform=linux/amd64 node:22-slim
WORKDIR app
COPY ./api/dist ./api/dist
@@ -9,8 +9,10 @@ WORKDIR api
RUN npm ci
WORKDIR /
COPY ./api/entrypoint.sh ./entrypoint.sh
RUN chmod +x entrypoint.sh
EXPOSE 3000
CMD ["node", "./app/api/dist/main.js"]
ENTRYPOINT ["/entrypoint.sh"]
# ENTRYPOINT ["tail", "-f", "/dev/null"]

View File

@@ -1,23 +1,3 @@
# Flying
# Flying (React and NestJS)
A web app for tracking pilot flight hours and sharing flight tracks.
## Built With
## Getting Started
### Prerequisites
### Installation
## Usage
## Roadmap
- [ ] Expense tracking for plane rental, instructor fees, and fuel rates
- [ ] Export logbook to CSV
- [ ]
## License
## Contact
A pilot logbook app

View File

@@ -1,4 +1,3 @@
# Flying API
An API for the Flying app.
...
An API for the Flying app.

3
api/entrypoint.sh Normal file
View File

@@ -0,0 +1,3 @@
#!/bin/bash
npx typeorm migration:run -d ./app/api/dist/database/data-source.js
node ./app/api/dist/main.js

View File

@@ -1,6 +1,6 @@
{
"name": "api",
"version": "2.1.5",
"version": "2.0.0-alpha-1",
"description": "",
"author": "",
"private": true,
@@ -35,7 +35,7 @@
"@nestjs/serve-static": "^5.0.3",
"@nestjs/typeorm": "^11.0.0",
"@noahspan/azure-database": "^3.1.2",
"@noahspan/noahspan-modules": "^1.2.11",
"@noahspan/noahspan-modules": "^1.2.9",
"@schematics/angular": "^17.3.7",
"@types/multer": "^1.4.12",
"better-sqlite3": "^12.2.0",

View File

@@ -30,26 +30,25 @@ import { join } from 'path';
isGlobal: true,
load: [configuration]
}),
HealthModule,
// HealthModule,
LogModule,
MsGraphModule.registerAsync({
inject: [ConfigService],
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => {
return {
authority: configService.get<string>('authority'),
clientId: configService.get<string>('clientId'),
clientSecret: configService.get<string>('clientSecret'),
tenantId: configService.get<string>('tenantId')
}
}
}),
PilotModule,
ServeStaticModule.forRoot({
rootPath: join(__dirname, '../..', 'client', 'dist')
}),
TrackModule,
TypeOrmModule.forRoot(dataSourceOptions)
TypeOrmModule.forRoot(dataSourceOptions),
MsGraphModule.registerAsync({
inject: [ConfigService],
imports: [ConfigModule],
useFactory: async (configService: ConfigService) => {
return {
clientId: configService.get<string>('clientId'),
clientSecret: configService.get<string>('clientSecret'),
tenantId: configService.get<string>('tenantId')
}
}
})
],
providers: [
{

View File

@@ -1,7 +1,6 @@
export default () => ({
azureStorageConnectionString: process.env.AZURE_STORAGE_CONNECTION_STRING,
audience: process.env.AUDIENCE,
authority: process.env.AUTHORITY,
clientId: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET,
issuer: process.env.ISSUER_URL,

View File

@@ -11,8 +11,7 @@ export const dataSourceOptions: DataSourceOptions = {
database: configService.get<string>('DB_PATH'),
entities: ['../**/*.entity.js'],
migrations: ['./migrations/*.js'],
synchronize: configService.get<boolean>('DB_SYNC'),
migrationsRun: true
synchronize: configService.get<boolean>('DB_SYNC')
}
const dataSource = new DataSource(dataSourceOptions);

View File

@@ -1,4 +1,4 @@
import { PilotEntity } from '../pilot/pilot.entity';
import { PilotEntity } from 'src/pilot/pilot.entity';
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
@Entity({ name: 'endorsements' })

View File

@@ -48,10 +48,10 @@ import { ConfigService } from '@nestjs/config';
return fileUrl;
}
async downloadFile(containerName: string, logId: string, fileName: string): Promise<string> {
async downloadFile(containerName: string, rowKey: string, fileName: string): Promise<string> {
this.containerName = containerName;
const blockBlobClient = await this.getBlobClient(`${logId}/${fileName}`);
const blockBlobClient = await this.getBlobClient(`${rowKey}/${fileName}`);
const downloadBlockBlobResponse = await blockBlobClient.download();
const downloaded: string = (await this.streamToBuffer(downloadBlockBlobResponse.readableStreamBody)).toString()

View File

@@ -1,25 +1,42 @@
import { Test, TestingModule } from "@nestjs/testing";
import { HealthController } from "./health.controller"
import { HttpStatus } from "@nestjs/common";
import { Test, TestingModule } from '@nestjs/testing';
import { HealthController } from './health.controller';
import { HealthService } from './health.service';
describe('HealthController', () => {
let controller: HealthController;
let controller; HealthController;
const mockHealthService = {
isDatabaseConnected: jest.fn()
}
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [HealthController]
controllers: [HealthController],
providers: [HealthService]
}).compile();
controller = module.get<HealthController>(HealthController);
})
it('should be defined', () => {
it('isHealthy => should return true', () => {
expect(controller).toBeDefined();
})
});
it('should return database connected', async () => {
jest.spyOn(mockHealthService, 'isDatabaseConnected').mockReturnValue(true);
it('isHealth => should return status ok', async () => {
const result = await controller.isHealthy();
expect(result).toEqual(HttpStatus.OK);
expect(mockHealthService.isDatabaseConnected).toHaveBeenCalled();
expect(result).toEqual(true);
})
it('isHealthy => should return error', async () => {
jest.spyOn(mockHealthService, 'isDatabaseConnected').mockReturnValue(false);
const result = await controller.isHealthy();
expect(mockHealthService.isDatabaseConnected).toHaveBeenCalled();
expect(result).toEqual(false);
})
})

View File

@@ -1,13 +1,27 @@
import {
Controller,
Get,
HttpStatus,
HttpException,
} from '@nestjs/common';
import { HealthService } from './health.service';
import { CustomError } from 'src/error/customError';
@Controller('health')
export class HealthController {
constructor(
private readonly healthService: HealthService
) {}
@Get()
async isHealthy(): Promise<HttpStatus> {
return HttpStatus.OK
async isHealthy(): Promise<boolean> {
try {
return await this.healthService.isDatabaseConnected();
} catch (error) {
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode);
}
}
}

View File

@@ -1,7 +1,11 @@
import { Module } from '@nestjs/common';
import { HealthController } from './health.controller';
import { HealthService } from './health.service';
@Module({
controllers: [HealthController],
providers: [
HealthService
]
})
export class HealthModule {}

View File

@@ -0,0 +1,14 @@
import { Test, TestingModule } from '@nestjs/testing';
import { HealthService } from './health.service';
describe('HealthService', () => {
let service: HealthService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [HealthService]
}).compile();
service = module.get<HealthService>(HealthService);
});
});

View File

@@ -0,0 +1,22 @@
import { Injectable } from '@nestjs/common';
import { CustomError } from '../error/customError';
import { DataSource } from 'typeorm';
@Injectable()
export class HealthService {
constructor(private dataSource: DataSource) {}
async isDatabaseConnected(): Promise<boolean> {
try {
const isDatabaseConnected: boolean = this.dataSource.isInitialized;
if (isDatabaseConnected) {
return isDatabaseConnected;
} else {
throw new CustomError('Database not connected', 'Database not connected', 400)
}
} catch (error) {
throw error;
}
}
}

View File

@@ -0,0 +1,5 @@
import { JwtPayload } from "jwt-decode";
export interface CustomJwtPayload extends JwtPayload {
roles: string[];
}

View File

@@ -1,449 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { LogController } from './log.controller';
import { LogService } from './log.service';
import { LogDto } from './log.dto';
import { LogEntity } from './log.entity';
import { FileService } from '../file/file.service';
import { ConfigService } from '@nestjs/config';
describe('LogController', () => {
let controller: LogController;
const mockLogService = {
create: jest.fn(),
delete: jest.fn(),
find: jest.fn(),
findLogsWithCount: jest.fn(),
findLogsWithTracks: jest.fn(),
update: jest.fn()
}
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [LogController],
providers: [
ConfigService,
FileService,
{
provide: LogService,
useValue: mockLogService
}
]
}).compile()
controller = module.get<LogController>(LogController);
})
it('should be defined', () => {
expect(controller).toBeDefined();
})
it('find => should find a log by id', async () => {
const log = {
id: '95834f84-0a02-44d3-884e-a20237adeca0',
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
date: new Date('2026-02-21'),
aircraftMakeModel: 'Cessna 172M',
aircraftIdentity: 'N12345',
routeFrom: 'KMSP',
routeTo: 'KMSP',
durationOfFlight: 1,
singleEngineLand: 1,
simulatorAtd: null,
landingsDay: 1,
landingsNight: null,
groundTrainingReceived: null,
flightTrainingReceived: null,
crossCountry: 1,
night: null,
solo: 1,
pilotInCommand: 1,
instrumentActual: null,
instrumentSimulated: null,
instrumentApproaches: null,
instrumentHolds: null,
instrumentNavTrack: null,
notes: 'This is a test',
tracks: []
} as LogEntity
jest.spyOn(mockLogService, 'find').mockReturnValue(log);
const result = await controller.find(log.id);
expect(result).toEqual(log);
expect(mockLogService.find).toHaveBeenCalled();
expect(mockLogService.find).toHaveBeenCalledWith(log.id);
})
it('find => should fail to find a log by id', async () => {
const id: string = '95834f84-0a02-44d3-884e-a20237adeca0';
jest.spyOn(mockLogService, 'find').mockRejectedValue(new Error('Log not found'))
try {
await controller.find(id);
fail('find did not throw error')
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect(mockLogService.find).toHaveBeenCalled();
expect(mockLogService.find).toHaveBeenCalledWith(id);
expect(mockLogService.find).rejects.toThrow('Log not found')
}
})
it('findLogsWithCount => should find logs with count', async () => {
const log = {
id: '95834f84-0a02-44d3-884e-a20237adeca0',
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
date: new Date('2026-02-21'),
aircraftMakeModel: 'Cessna 172M',
aircraftIdentity: 'N12345',
routeFrom: 'KMSP',
routeTo: 'KMSP',
durationOfFlight: 1,
singleEngineLand: 1,
simulatorAtd: null,
landingsDay: 1,
landingsNight: null,
groundTrainingReceived: null,
flightTrainingReceived: null,
crossCountry: 1,
night: null,
solo: 1,
pilotInCommand: 1,
instrumentActual: null,
instrumentSimulated: null,
instrumentApproaches: null,
instrumentHolds: null,
instrumentNavTrack: null,
notes: 'This is a test',
tracks: [
{
"id": "4645ce0c-6fd7-432c-8089-777a7139cf7e",
"url": "http://127.0.0.1:10000/devstoreaccount1/tracks/0e7a641a-0264-47f0-88bf-7c296c842d8b/FlightAware_N70392_KANE_KONA_20250628.kml",
"order": 1
},
{
"id": "5ce26898-a18b-46f4-b989-3fb7acdace08",
"url": "http://127.0.0.1:10000/devstoreaccount1/tracks/0e7a641a-0264-47f0-88bf-7c296c842d8b/FlightAware_N70392_KONA_KANE_20250628.kml",
"order": 2
}
]
} as LogEntity;
const logs = [log]
const count = 1
const morePages = false
jest.spyOn(mockLogService, 'findLogsWithCount').mockReturnValue({
entities: logs,
total: count,
hasNextPage: morePages
});
const {entities, total, hasNextPage} = await controller.findLogsWithCount();
expect(entities).toEqual(logs);
expect(total).toEqual(count);
expect(hasNextPage).toEqual(morePages)
expect(mockLogService.findLogsWithCount).toHaveBeenCalled();
})
it('findLogsWithCount => should fail to find logs with count', async () => {
jest.spyOn(mockLogService, 'findLogsWithCount').mockRejectedValue(new Error('Logs not found'))
try {
await controller.findLogsWithCount();
fail('findLogsWithCount did not throw error');
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect(mockLogService.findLogsWithCount).toHaveBeenCalled();
expect(mockLogService.findLogsWithCount).rejects.toThrow('Logs not found')
}
})
it('findLogsWithTracks => should find logs with tracks', async () => {
const log = {
id: '95834f84-0a02-44d3-884e-a20237adeca0',
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
date: new Date('2026-02-21'),
aircraftMakeModel: 'Cessna 172M',
aircraftIdentity: 'N12345',
routeFrom: 'KMSP',
routeTo: 'KMSP',
durationOfFlight: 1,
singleEngineLand: 1,
simulatorAtd: null,
landingsDay: 1,
landingsNight: null,
groundTrainingReceived: null,
flightTrainingReceived: null,
crossCountry: 1,
night: null,
solo: 1,
pilotInCommand: 1,
instrumentActual: null,
instrumentSimulated: null,
instrumentApproaches: null,
instrumentHolds: null,
instrumentNavTrack: null,
notes: 'This is a test',
tracks: [
{
"id": "4645ce0c-6fd7-432c-8089-777a7139cf7e",
"url": "http://127.0.0.1:10000/devstoreaccount1/tracks/0e7a641a-0264-47f0-88bf-7c296c842d8b/FlightAware_N70392_KANE_KONA_20250628.kml",
"order": 1
},
{
"id": "5ce26898-a18b-46f4-b989-3fb7acdace08",
"url": "http://127.0.0.1:10000/devstoreaccount1/tracks/0e7a641a-0264-47f0-88bf-7c296c842d8b/FlightAware_N70392_KONA_KANE_20250628.kml",
"order": 2
}
]
} as LogEntity;
const logs = [log]
const count = 1
const morePages = false
jest.spyOn(mockLogService, 'findLogsWithTracks').mockReturnValue({
entities: logs,
total: count,
hasNextPage: morePages
});
const {entities, total, hasNextPage} = await controller.findLogsWithTracks();
expect(entities).toEqual(logs);
expect(total).toEqual(count);
expect(hasNextPage).toEqual(morePages)
expect(mockLogService.findLogsWithTracks).toHaveBeenCalled();
})
it('findLogsWithCount => should fail to find logs with tracks', async () => {
jest.spyOn(mockLogService, 'findLogsWithTracks').mockRejectedValue(new Error('Logs not found'))
try {
await controller.findLogsWithTracks();
fail('findLogsWithTracks did not throw error');
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect(mockLogService.findLogsWithTracks).toHaveBeenCalled();
expect(mockLogService.findLogsWithTracks).rejects.toThrow('Logs not found')
}
})
it('create => should create a new log', async () => {
const logDto = {
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
date: new Date('2026-02-21'),
aircraftMakeModel: 'Cessna 172M',
aircraftIdentity: 'N12345',
routeFrom: 'KMSP',
routeTo: 'KMSP',
durationOfFlight: 1,
singleEngineLand: 1,
simulatorAtd: null,
landingsDay: 1,
landingsNight: null,
groundTrainingReceived: null,
flightTrainingReceived: null,
crossCountry: 1,
night: null,
solo: 1,
pilotInCommand: 1,
instrumentActual: null,
instrumentSimulated: null,
instrumentApproaches: null,
instrumentHolds: null,
instrumentNavTrack: null,
notes: 'This is a test'
} as LogDto;
const log = {
id: '95834f84-0a02-44d3-884e-a20237adeca0',
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
date: new Date('2026-02-21'),
aircraftMakeModel: 'Cessna 172M',
aircraftIdentity: 'N12345',
routeFrom: 'KMSP',
routeTo: 'KMSP',
durationOfFlight: 1,
singleEngineLand: 1,
simulatorAtd: null,
landingsDay: 1,
landingsNight: null,
groundTrainingReceived: null,
flightTrainingReceived: null,
crossCountry: 1,
night: null,
solo: 1,
pilotInCommand: 1,
instrumentActual: null,
instrumentSimulated: null,
instrumentApproaches: null,
instrumentHolds: null,
instrumentNavTrack: null,
notes: 'This is a test',
tracks: []
} as LogEntity;
jest.spyOn(mockLogService, 'create').mockReturnValue(log);
const result = await controller.create(logDto);
expect(mockLogService.create).toHaveBeenCalled();
expect(mockLogService.create).toHaveBeenCalledWith(logDto);
expect(result).toEqual(log);
})
it('create => should fail to create a new log', async () => {
const logDto = {
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
date: new Date('2026-02-21'),
aircraftMakeModel: 'Cessna 172M',
aircraftIdentity: 'N12345',
routeFrom: 'KMSP',
routeTo: 'KMSP',
durationOfFlight: 1,
singleEngineLand: 1,
simulatorAtd: null,
landingsDay: 1,
landingsNight: null,
groundTrainingReceived: null,
flightTrainingReceived: null,
crossCountry: 1,
night: null,
solo: 1,
pilotInCommand: 1,
instrumentActual: null,
instrumentSimulated: null,
instrumentApproaches: null,
instrumentHolds: null,
instrumentNavTrack: null,
notes: 'This is a test'
} as LogDto;
jest.spyOn(mockLogService, 'create').mockRejectedValue(new Error('Log failed to create'))
try {
await controller.create(logDto);
fail('create did not throw error')
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect(mockLogService.create).toHaveBeenCalled();
expect(mockLogService.create).toHaveBeenCalledWith(logDto);
expect(mockLogService.create).rejects.toThrow('Log failed to create')
}
})
it('update => should update an existing log', async () => {
const id: string = '95834f84-0a02-44d3-884e-a20237adeca0';
const logDto = {
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
date: new Date('2026-02-21'),
aircraftMakeModel: 'Cessna 172M',
aircraftIdentity: 'N12345',
routeFrom: 'KMSP',
routeTo: 'KMSP',
durationOfFlight: 1,
singleEngineLand: 1,
simulatorAtd: null,
landingsDay: 1,
landingsNight: null,
groundTrainingReceived: null,
flightTrainingReceived: null,
crossCountry: 1,
night: null,
solo: 1,
pilotInCommand: 1,
instrumentActual: null,
instrumentSimulated: null,
instrumentApproaches: null,
instrumentHolds: null,
instrumentNavTrack: null,
notes: 'This is a test'
} as LogDto
jest.spyOn(mockLogService, 'update').mockReturnValue(logDto);
const result = await controller.update(id, logDto);
expect(result).toEqual(logDto);
expect(mockLogService.update).toHaveBeenCalled();
expect(mockLogService.update).toHaveBeenCalledWith(id, logDto);
})
it('update => should fail to update an exising log', async () => {
const id: string = '95834f84-0a02-44d3-884e-a20237adeca0';
const logDto = {
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
date: new Date('2026-02-21'),
aircraftMakeModel: 'Cessna 172M',
aircraftIdentity: 'N12345',
routeFrom: 'KMSP',
routeTo: 'KMSP',
durationOfFlight: 1,
singleEngineLand: 1,
simulatorAtd: null,
landingsDay: 1,
landingsNight: null,
groundTrainingReceived: null,
flightTrainingReceived: null,
crossCountry: 1,
night: null,
solo: 1,
pilotInCommand: 1,
instrumentActual: null,
instrumentSimulated: null,
instrumentApproaches: null,
instrumentHolds: null,
instrumentNavTrack: null,
notes: 'This is a test'
} as LogDto;
jest.spyOn(mockLogService, 'update').mockRejectedValue(new Error('Log failed to update'))
try {
await controller.update(id, logDto);
fail('update did not throw error')
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect(mockLogService.update).toHaveBeenCalled();
expect(mockLogService.update).toHaveBeenCalledWith(id, logDto);
expect(mockLogService.update).rejects.toThrow('Log failed to update')
}
})
it('delete => should delete and existing log', async () => {
const id: string = '95834f84-0a02-44d3-884e-a20237adeca0';
jest.spyOn(mockLogService, 'delete');
const result = await controller.delete(id);
expect(mockLogService.delete).toHaveBeenCalled();
expect(mockLogService.delete).toHaveBeenCalledWith(id);
})
it('delete => should fail to delete and exising log', async () => {
const id: string = '95834f84-0a02-44d3-884e-a20237adeca0';
jest.spyOn(mockLogService, 'delete').mockRejectedValue(new Error('Log failed to delete'));
try {
await controller.delete(id);
fail('delete did not throw error')
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect(mockLogService.delete).toHaveBeenCalled();
expect(mockLogService.delete).toHaveBeenCalledWith(id);
expect(mockLogService.delete).rejects.toThrow('Log failed to delete')
}
})
})

View File

@@ -7,7 +7,6 @@ import {
Param,
Post,
Put,
Query,
UseGuards,
UseInterceptors
} from '@nestjs/common';
@@ -20,7 +19,6 @@ import { LogInterceptor } from './log.interceptor';
import { FileService } from '../file/file.service';
import { DeleteResult, InsertResult, UpdateResult } from 'typeorm';
import { Reflector } from '@nestjs/core';
import { Logs } from './logs.interface';
const reflector = new Reflector();
@@ -32,52 +30,44 @@ export class LogController {
private readonly logService: LogService
) {}
@Get()
@Public()
async findLogsWithCount(@Query('skip') skip?, @Query('take') take?: number,): Promise<Logs> {
try {
return await this.logService.findLogsWithCount(skip, take)
} catch (error) {
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode)
}
}
@Get('flights')
@Public()
async findLogsWithTracks(@Query('skip') skip?, @Query('take') take?: number,): Promise<Logs> {
try {
return await this.logService.findLogsWithTracks(skip, take)
} catch (error) {
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode);
}
}
@Get(':id')
@Public()
async find(
@Param('id') id: string,
): Promise<LogEntity> {
try {
console.log(id)
return await this.logService.find(id);
} catch (error) {
const customError = error as CustomError;
console.log(error)
throw new HttpException(customError.message, customError.statusCode);
}
}
@Get()
@Public()
async findAll(): Promise<LogEntity[]> {
try {
console.log('blah')
return await this.logService.findAll();
} catch (error) {
const customError = error as CustomError;
console.log(error)
throw new HttpException(customError.message, customError.statusCode);
}
}
@Post()
@UseGuards(AuthGuard)
async create(@Body() logDto: LogDto) {
async create(@Body() logDto: LogDto): Promise<InsertResult> {
try {
return await this.logService.create(logDto);
} catch (error) {
const customError = error as CustomError;
console.log(error)
throw new HttpException(customError.message, customError.statusCode);
}
}
@@ -87,12 +77,14 @@ export class LogController {
async update(
@Param('id') id: string,
@Body() logDto: LogDto
) {
): Promise<UpdateResult> {
try {
console.log(id)
console.log(logDto)
return await this.logService.update(id, logDto);
} catch (error) {
const customError = error as CustomError;
console.log(error)
throw new HttpException(customError.message, customError.statusCode);
}
}
@@ -105,6 +97,7 @@ export class LogController {
try {
return await this.logService.delete(id);
} catch (error) {
console.log(error)
const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode);

View File

@@ -1,3 +1,5 @@
import { PilotEntity } from "src/pilot/pilot.entity";
export class LogDto {
pilotId: string;
date: Date;

View File

@@ -1,6 +1,5 @@
import { PilotEntity } from '../pilot/pilot.entity';
import { TrackEntity } from '../track/track.entity';
import { ColumnNumericTransformer } from '../transformers/columnNumeric.transformer';
import { PilotEntity } from 'src/pilot/pilot.entity';
import { TrackEntity } from 'src/track/track.entity';
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, OneToMany } from 'typeorm';
@Entity({ name: 'logs' })
@@ -26,131 +25,52 @@ export class LogEntity {
@Column()
routeTo: string;
@Column('numeric', {
precision: 10,
scale: 1,
transformer: new ColumnNumericTransformer()
})
@Column()
durationOfFlight: number;
@Column('numeric', {
nullable: true,
precision: 10,
scale: 1,
transformer: new ColumnNumericTransformer(),
})
@Column({ nullable: true })
singleEngineLand: number | null;
@Column('numeric', {
nullable: true,
precision: 10,
scale: 1,
transformer: new ColumnNumericTransformer(),
})
@Column({ nullable: true })
simulatorAtd: number | null;
@Column('numeric', {
nullable: true,
precision: 10,
scale: 1,
transformer: new ColumnNumericTransformer(),
})
@Column({ nullable: true })
landingsDay: number | null;
@Column('numeric', {
nullable: true,
precision: 10,
scale: 1,
transformer: new ColumnNumericTransformer(),
})
@Column({ nullable: true })
landingsNight: number | null;
@Column('numeric', {
nullable: true,
precision: 10,
scale: 1,
transformer: new ColumnNumericTransformer(),
})
@Column({ nullable: true })
groundTrainingReceived: number | null;
@Column('numeric', {
nullable: true,
precision: 10,
scale: 1,
transformer: new ColumnNumericTransformer(),
})
@Column({ nullable: true })
flightTrainingReceived: number | null;
@Column('numeric', {
nullable: true,
precision: 10,
scale: 1,
transformer: new ColumnNumericTransformer(),
})
@Column({ nullable: true })
crossCountry: number | null;
@Column('numeric', {
nullable: true,
precision: 10,
scale: 1,
transformer: new ColumnNumericTransformer(),
})
@Column({ nullable: true })
night: number | null;
@Column('numeric', {
nullable: true,
precision: 10,
scale: 1,
transformer: new ColumnNumericTransformer(),
})
@Column({ nullable: true })
solo: number | null;
@Column('numeric', {
nullable: true,
precision: 10,
scale: 1,
transformer: new ColumnNumericTransformer(),
})
@Column({ nullable: true })
pilotInCommand: number | null;
@Column('numeric', {
nullable: true,
precision: 10,
scale: 1,
transformer: new ColumnNumericTransformer(),
})
@Column({ nullable: true })
instrumentActual: number | null;
@Column('numeric', {
nullable: true,
precision: 10,
scale: 1,
transformer: new ColumnNumericTransformer(),
})
@Column({ nullable: true })
instrumentSimulated: number | null;
@Column('numeric', {
nullable: true,
precision: 10,
scale: 1,
transformer: new ColumnNumericTransformer(),
})
@Column({ nullable: true })
instrumentApproaches: number | null;
@Column('numeric', {
nullable: true,
precision: 10,
scale: 1,
transformer: new ColumnNumericTransformer(),
})
@Column({ nullable: true })
instrumentHolds: number | null;
@Column('numeric', {
nullable: true,
precision: 10,
scale: 1,
transformer: new ColumnNumericTransformer(),
})
@Column({ nullable: true })
instrumentNavTrack: number | null;
@Column({ nullable: true })

View File

@@ -2,6 +2,7 @@ import { CallHandler, ExecutionContext, NestInterceptor, UnauthorizedException }
import { Observable, map } from 'rxjs';
import { LogEntity } from './log.entity';
import { jwtDecode } from 'jwt-decode';
import { CustomJwtPayload } from 'src/interfaces/customJwtPayload.interface';
import { IS_PUBLIC_KEY } from '@noahspan/noahspan-modules';
import { Reflector } from '@nestjs/core';
@@ -15,62 +16,42 @@ export class LogInterceptor implements NestInterceptor {
]);
return handler.handle().pipe(
map((data: any) => {
map((data: LogEntity[]) => {
const req = context.switchToHttp().getRequest();
const limitData = (log: LogEntity) => {
return {
id: log.id,
pilot: {
name: log.pilot.name
},
date: log.date,
aircraftMakeModel: log.aircraftMakeModel,
routeFrom: log.routeFrom,
routeTo: log.routeTo,
durationOfFlight: log.durationOfFlight,
tracks: log.tracks,
};
const limitData = (data) => {
return data.map((log: LogEntity) => {
return {
id: log.id,
pilot: {
name: log.pilot.name
},
date: log.date,
aircraftMakeModel: log.aircraftMakeModel,
routeFrom: log.routeFrom,
routeTo: log.routeTo,
durationOfFlight: log.durationOfFlight,
tracks: log.tracks,
};
});
}
if (req.headers.authorization) {
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1];
const jwtPayload = jwtDecode(token);
const rolesKeyName = Object.keys(jwtPayload).find((key) => key.includes('roles'));
const jwtPayload: CustomJwtPayload = jwtDecode(token);
if (jwtPayload[rolesKeyName].includes('Flying.Read')) {
if (data.entities) {
const logs = data.entities.map((entity) => limitData(entity));
if (jwtPayload.roles.includes('Flying.Read')) {
const logs = limitData(data);
return {
entities: logs,
total: data.total,
hasNextPage: data.hasNextPage
};
} else {
const log = limitData(data);
return log;
}
return logs;
} else {
return data;
}
} else if (!req.headers.authorization && isPublic) {
if (data.entities) {
const publicData = data.entities.map((entity) => limitData(entity))
const logs = publicData.slice(0, 5)
const publicData = limitData(data)
const logs = publicData.slice(0, 5)
return {
entities: logs,
total: logs.length,
hasNextPage: false
};
} else {
const publicData = limitData(data);
return publicData
}
return logs;
}
})
);

View File

@@ -5,7 +5,7 @@ import { LogEntity } from './log.entity';
import { ConfigService } from '@nestjs/config';
import { FileService } from '../file/file.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PilotModule } from '../pilot/pilot.module';
import { PilotModule } from 'src/pilot/pilot.module';
@Module({
imports: [

View File

@@ -1,366 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { LogService } from './log.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import { LogEntity } from './log.entity';
import { LogDto } from './log.dto';
import { FileService } from '../file/file.service';
import { ConfigService } from '@nestjs/config';
import { PilotService } from '../pilot/pilot.service';
import { PilotEntity } from '../pilot/pilot.entity';
describe('LogService', () => {
let service: LogService;
const mockFileService = {
deleteFolder: jest.fn()
}
const mockQueryBuilder = {
createQueryBuilder: jest.fn().mockReturnThis(),
innerJoinAndSelect: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
take: jest.fn().mockReturnThis(),
getManyAndCount: jest.fn()
}
const mockLogRepository = {
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder),
delete: jest.fn(),
findAndCount: jest.fn(),
findOne: jest.fn(),
findOneBy: jest.fn(),
save: jest.fn(),
}
const mockPilotRepository = {
create: jest.fn(),
delete: jest.fn(),
find: jest.fn(),
findAll: jest.fn(),
update: jest.fn()
}
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ConfigService,
LogService,
PilotService,
{
provide: FileService,
useValue: mockFileService
},
{
provide: getRepositoryToken(LogEntity),
useValue: mockLogRepository
},
{
provide: getRepositoryToken(PilotEntity),
useValue: mockPilotRepository
}
]
}).compile();
service = module.get<LogService>(LogService);
})
it('should be defined', () => {
expect(service).toBeDefined();
})
it('create => should create a log entry', async () => {
const logDto = {
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
date: new Date('2026-02-21'),
aircraftMakeModel: 'Cessna 172M',
aircraftIdentity: 'N12345',
routeFrom: 'KMSP',
routeTo: 'KMSP',
durationOfFlight: 1,
singleEngineLand: 1,
simulatorAtd: null,
landingsDay: 1,
landingsNight: null,
groundTrainingReceived: null,
flightTrainingReceived: null,
crossCountry: 1,
night: null,
solo: 1,
pilotInCommand: 1,
instrumentActual: null,
instrumentSimulated: null,
instrumentApproaches: null,
instrumentHolds: null,
instrumentNavTrack: null,
notes: 'This is a test'
} as LogDto
jest.spyOn(mockLogRepository, 'save').mockReturnValue(logDto);
const result = await service.create(logDto);
expect(mockLogRepository.save).toHaveBeenCalled();
expect(mockLogRepository.save).toHaveBeenCalledWith(logDto);
})
it('delete => should delete a log entry', async () => {
const id: string = '';
const log = {
id: 'd685f1ca-28e0-40b9-8713-74467db12965',
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
date: new Date('2026-02-21'),
aircraftMakeModel: 'Cessna 172M',
aircraftIdentity: 'N12345',
routeFrom: 'KMSP',
routeTo: 'KMSP',
durationOfFlight: 1,
singleEngineLand: 1,
simulatorAtd: null,
landingsDay: 1,
landingsNight: null,
groundTrainingReceived: null,
flightTrainingReceived: null,
crossCountry: 1,
night: null,
solo: 1,
pilotInCommand: 1,
instrumentActual: null,
instrumentSimulated: null,
instrumentApproaches: null,
instrumentHolds: null,
instrumentNavTrack: null,
notes: 'This is a test',
tracks: []
} as LogEntity;
jest.spyOn(mockFileService, 'deleteFolder').mockReturnValue(undefined);
jest.spyOn(mockLogRepository, 'delete').mockReturnValue(log);
const result = await service.delete(id);
expect(result).toEqual(log);
expect(mockLogRepository.delete).toHaveBeenCalled();
expect(mockLogRepository.delete).toHaveBeenCalledWith({ id: id })
})
it('find => should find a log entry by id', async () => {
const id: string = 'd685f1ca-28e0-40b9-8713-74467db12965';
const log = {
id: 'd685f1ca-28e0-40b9-8713-74467db12965',
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
date: new Date('2026-02-21'),
aircraftMakeModel: 'Cessna 172M',
aircraftIdentity: 'N12345',
routeFrom: 'KMSP',
routeTo: 'KMSP',
durationOfFlight: 1,
singleEngineLand: 1,
simulatorAtd: null,
landingsDay: 1,
landingsNight: null,
groundTrainingReceived: null,
flightTrainingReceived: null,
crossCountry: 1,
night: null,
solo: 1,
pilotInCommand: 1,
instrumentActual: null,
instrumentSimulated: null,
instrumentApproaches: null,
instrumentHolds: null,
instrumentNavTrack: null,
notes: 'This is a test',
tracks: []
} as LogEntity;
jest.spyOn(mockLogRepository, 'findOne').mockReturnValue(log);
const result = await service.find(id);
expect(result).toEqual(log);
expect(mockLogRepository.findOne).toHaveBeenCalled();
expect(mockLogRepository.findOne).toHaveBeenCalledWith({
relations: [
'pilot',
'tracks'
],
where: {
id: id
}
})
})
it('findLogsWithCount => should find log entries with count', async () => {
const log = {
id: 'd685f1ca-28e0-40b9-8713-74467db12965',
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
date: new Date('2026-02-21'),
aircraftMakeModel: 'Cessna 172M',
aircraftIdentity: 'N12345',
routeFrom: 'KMSP',
routeTo: 'KMSP',
durationOfFlight: 1,
singleEngineLand: 1,
simulatorAtd: null,
landingsDay: 1,
landingsNight: null,
groundTrainingReceived: null,
flightTrainingReceived: null,
crossCountry: 1,
night: null,
solo: 1,
pilotInCommand: 1,
instrumentActual: null,
instrumentSimulated: null,
instrumentApproaches: null,
instrumentHolds: null,
instrumentNavTrack: null,
notes: 'This is a test',
tracks: [
{
"id": "4645ce0c-6fd7-432c-8089-777a7139cf7e",
"url": "http://127.0.0.1:10000/devstoreaccount1/tracks/0e7a641a-0264-47f0-88bf-7c296c842d8b/FlightAware_N70392_KANE_KONA_20250628.kml",
"order": 1
},
{
"id": "5ce26898-a18b-46f4-b989-3fb7acdace08",
"url": "http://127.0.0.1:10000/devstoreaccount1/tracks/0e7a641a-0264-47f0-88bf-7c296c842d8b/FlightAware_N70392_KONA_KANE_20250628.kml",
"order": 2
}
]
} as LogEntity;
const logs = [log];
const count = 1
const morePages = false
jest.spyOn(mockQueryBuilder, 'getManyAndCount').mockReturnValue([logs, count, morePages]);
const {entities, total, hasNextPage} = await service.findLogsWithCount();
expect(entities).toEqual(logs);
expect(count).toEqual(total);
expect(hasNextPage).toEqual(morePages);
expect(mockQueryBuilder.getManyAndCount).toHaveBeenCalled();
})
it('findLogsWithTracks => should find log entries with tracks', async () => {
const log = {
id: 'd685f1ca-28e0-40b9-8713-74467db12965',
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
date: new Date('2026-02-21'),
aircraftMakeModel: 'Cessna 172M',
aircraftIdentity: 'N12345',
routeFrom: 'KMSP',
routeTo: 'KMSP',
durationOfFlight: 1,
singleEngineLand: 1,
simulatorAtd: null,
landingsDay: 1,
landingsNight: null,
groundTrainingReceived: null,
flightTrainingReceived: null,
crossCountry: 1,
night: null,
solo: 1,
pilotInCommand: 1,
instrumentActual: null,
instrumentSimulated: null,
instrumentApproaches: null,
instrumentHolds: null,
instrumentNavTrack: null,
notes: 'This is a test',
tracks: [
{
"id": "4645ce0c-6fd7-432c-8089-777a7139cf7e",
"url": "http://127.0.0.1:10000/devstoreaccount1/tracks/0e7a641a-0264-47f0-88bf-7c296c842d8b/FlightAware_N70392_KANE_KONA_20250628.kml",
"order": 1
},
{
"id": "5ce26898-a18b-46f4-b989-3fb7acdace08",
"url": "http://127.0.0.1:10000/devstoreaccount1/tracks/0e7a641a-0264-47f0-88bf-7c296c842d8b/FlightAware_N70392_KONA_KANE_20250628.kml",
"order": 2
}
]
} as LogEntity;
const logs = [log];
const count = 1
const morePages = false
jest.spyOn(mockQueryBuilder, 'getManyAndCount').mockResolvedValue([logs, count, morePages])
const {entities, total, hasNextPage} = await service.findLogsWithTracks();
expect(entities).toEqual(logs);
expect(count).toEqual(total);
expect(hasNextPage).toEqual(morePages);
expect(mockQueryBuilder.getManyAndCount).toHaveBeenCalled();
})
it('update => should update a log entry', async () => {
const id: string = 'd685f1ca-28e0-40b9-8713-74467db12965';
const logDto = {
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
date: new Date('2026-02-21'),
aircraftMakeModel: 'Cessna 172M',
aircraftIdentity: 'N12345',
routeFrom: 'KMSP',
routeTo: 'KMSP',
durationOfFlight: 1,
singleEngineLand: 1,
simulatorAtd: null,
landingsDay: 1,
landingsNight: null,
groundTrainingReceived: null,
flightTrainingReceived: null,
crossCountry: 1,
night: null,
solo: 1,
pilotInCommand: 1,
instrumentActual: null,
instrumentSimulated: null,
instrumentApproaches: null,
instrumentHolds: null,
instrumentNavTrack: null,
notes: 'This is a test'
} as LogDto
const log = {
id: 'd685f1ca-28e0-40b9-8713-74467db12965',
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
date: new Date('2026-02-21'),
aircraftMakeModel: 'Cessna 172M',
aircraftIdentity: 'N12345',
routeFrom: 'KMSP',
routeTo: 'KMSP',
durationOfFlight: 1,
singleEngineLand: 1,
simulatorAtd: null,
landingsDay: 1,
landingsNight: null,
groundTrainingReceived: null,
flightTrainingReceived: null,
crossCountry: 1,
night: null,
solo: 1,
pilotInCommand: 1,
instrumentActual: null,
instrumentSimulated: null,
instrumentApproaches: null,
instrumentHolds: null,
instrumentNavTrack: null,
notes: 'This is a test',
tracks: []
} as LogEntity;
jest.spyOn(mockLogRepository, 'findOneBy').mockReturnValue(log);
jest.spyOn(mockLogRepository, 'save').mockReturnValue(logDto);
const result = await service.update(id, logDto);
expect(result).toEqual(logDto);
expect(mockLogRepository.findOneBy).toHaveBeenCalled();
expect(mockLogRepository.findOneBy).toHaveBeenCalledWith({ id: id });
expect(mockLogRepository.save).toHaveBeenCalled();
expect(mockLogRepository.save).toHaveBeenCalledWith(logDto);
})
})

View File

@@ -3,11 +3,10 @@ import { InjectRepository } from '@nestjs/typeorm';
import { LogEntity } from './log.entity';
import { DeleteResult, InsertResult, Repository, UpdateResult } from 'typeorm';
import { LogDto } from './log.dto';
import { PilotService } from '../pilot/pilot.service';
import { PilotEntity } from '../pilot/pilot.entity';
import { CustomError } from '../error/customError';
import { FileService } from '../file/file.service';
import { Logs } from './logs.interface';
import { PilotService } from 'src/pilot/pilot.service';
import { PilotEntity } from 'src/pilot/pilot.entity';
import { CustomError } from 'src/error/customError';
import { FileService } from 'src/file/file.service';
@Injectable()
export class LogService {
@@ -18,65 +17,42 @@ export class LogService {
) {}
async find(id: string): Promise<LogEntity> {
return await this.logRepository.findOne({
const logEntity: LogEntity = await this.logRepository.findOne({
where: { id: id },
relations: ['pilot', 'tracks']
});
return logEntity;
}
async findAll(): Promise<LogEntity[]> {
return await this.logRepository.find({
relations: ['pilot', 'tracks']
});
}
async findLogsWithCount(skip?: number, take?: number): Promise<Logs> {
// const [entities, total] = await this.logRepository.findAndCount({
// take,
// skip,
// order: {
// date: 'DESC'
// },
// relations: ['pilot', 'tracks']
// })
async create(logDto: LogDto): Promise<InsertResult> {
try{
const pilotEntity: PilotEntity = await this.pilotService.find(logDto.pilotId);
const [entities, total] = await this.logRepository
.createQueryBuilder('logs')
.innerJoinAndSelect('logs.pilot', 'pilot')
.orderBy('logs.date', 'DESC')
.skip(skip)
.take(take)
.getManyAndCount()
if (pilotEntity) {
const { pilotId, ...newLogDto } = logDto;
const log = this.logRepository.create({
...newLogDto,
pilot: pilotEntity
})
return {
entities,
total,
hasNextPage: skip + take < total
return this.logRepository.insert(log);
} else {
throw new CustomError('Pilot not found', 'Not found', 404);
}
} catch (error) {
throw error
}
}
async findLogsWithTracks(skip?: number, take?: number): Promise<Logs> {
const [entities, total] = await this.logRepository
.createQueryBuilder('logs')
.innerJoinAndSelect('logs.tracks', 'track')
.innerJoinAndSelect('logs.pilot', 'pilot')
.orderBy('logs.date', 'DESC')
.skip(skip)
.take(take)
.getManyAndCount();
return {
entities,
total,
hasNextPage: skip + take < total
}
}
async create(logDto: LogDto): Promise<LogDto> {
return this.logRepository.save(logDto);
}
async update(id: string, logDto: LogDto): Promise<LogDto> {
const logEntity: LogEntity = await this.logRepository.findOneBy({ id });
const logEntityUpdated = Object.assign(logEntity, logDto)
delete logEntityUpdated.tracks;
return await this.logRepository.save(logEntityUpdated);
async update(id: string, log: LogDto): Promise<UpdateResult> {
return await this.logRepository.update(id, log);
}
async delete(id: string): Promise<DeleteResult> {

View File

@@ -1,7 +0,0 @@
import { LogEntity } from "src/log/log.entity";
export interface Logs {
entities: LogEntity[],
total: number,
hasNextPage: boolean
}

View File

@@ -9,12 +9,16 @@ async function bootstrap() {
const httpService = new HttpService();
const app = await NestFactory.create(AppModule);
app.enableCors();
app.enableCors({
origin: 'http://localhost:8080', // Allow requests from your frontend's origin
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE',
credentials: true, // If you need to send cookies or authorization headers
});
app.setGlobalPrefix('api');
app.useGlobalFilters(new HttpExceptionFilter());
app.use(
session({
secret: process.env.SESSION_SECRET,
secret: 'blah',
resave: false,
saveUninitialized: false
})

View File

@@ -1,4 +1,4 @@
import { PilotEntity } from '../pilot/pilot.entity';
import { PilotEntity } from 'src/pilot/pilot.entity';
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
@Entity({ name: 'medical' })

View File

@@ -1,260 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PilotController } from "./pilot.controller";
import { PilotService } from './pilot.service';
import { PilotDto } from './pilot.dto';
import { PilotEntity } from './pilot.entity';
import { CustomError } from '../error/customError';
import { HttpException } from '@nestjs/common';
describe('PilotController', () => {
let controller: PilotController;
const mockPilotService = {
create: jest.fn(),
delete: jest.fn(),
find: jest.fn(),
findAll: jest.fn(),
update: jest.fn(),
}
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [PilotController],
providers: [
{
provide: PilotService,
useValue: mockPilotService
}
]
}).compile();
controller = module.get<PilotController>(PilotController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
it('find => should find a pilot by id', async () => {
const id: string = '39465ae3-7947-4cc2-b565-ca00a982fdd8'
const pilot = {
id: '39465ae3-7947-4cc2-b565-ca00a982fdd8',
name: 'Test pilot',
address: '123 Any Street',
city: 'Any Town',
state: 'Minnesota',
postalCode: '55123',
email: 'user@example.com',
phone: '555-555-1234',
userId: 'user@example.com',
logs: [],
certificates: [],
endorsements: [],
medical: []
} as PilotEntity;
jest.spyOn(mockPilotService, 'find').mockReturnValue(pilot);
const result = await controller.find(id);
expect(result).toEqual(pilot);
expect(mockPilotService.find).toHaveBeenCalled();
expect(mockPilotService.find).toHaveBeenCalledWith(id)
})
it('find => should fail to find a pilot by id', async () => {
const id: string = '39465ae3-7947-4cc2-b565-ca00a982fdd8'
jest.spyOn(mockPilotService, 'find').mockRejectedValue(new Error('Pilot not found'));
try {
await controller.find(id);
fail('find did not throw error')
} catch (error) {
expect(error).toBeInstanceOf(Error)
expect(mockPilotService.find).toHaveBeenCalled();
expect(mockPilotService.find).toHaveBeenCalledWith(id);
expect(mockPilotService.find).rejects.toThrow('Pilot not found')
}
})
it('findAll => should find all pilots', async () => {
const pilot = {
id: '39465ae3-7947-4cc2-b565-ca00a982fdd8',
name: 'Test pilot',
address: '123 Any Street',
city: 'Any Town',
state: 'Minnesota',
postalCode: '55123',
email: 'user@example.com',
phone: '555-555-1234',
userId: 'user@example.com',
logs: [],
certificates: [],
endorsements: [],
medical: []
} as PilotEntity;
const pilots = [pilot];
jest.spyOn(mockPilotService, 'findAll').mockReturnValue(pilots);
const result = await controller.findAll();
expect(result).toEqual(pilots);
expect(mockPilotService.findAll).toHaveBeenCalled();
})
it('findAll => should fail to find all pilots', async () => {
jest.spyOn(mockPilotService, 'findAll').mockRejectedValue(new Error('Pilots not found'));
try {
await controller.findAll();
fail('findAll did not throw error')
} catch (error) {
expect(error).toBeInstanceOf(Error)
expect(mockPilotService.findAll).toHaveBeenCalled();
expect(mockPilotService.findAll).rejects.toThrow('Pilots not found')
}
})
it('create => should create a new pilot', async () => {
const pilotDto = {
name: 'Test pilot',
address: '123 Any Street',
city: 'Any Town',
state: 'Minnesota',
postalCode: '55123',
email: 'user@example.com',
phone: '555-555-1234'
} as PilotDto
const pilot = {
id: '39465ae3-7947-4cc2-b565-ca00a982fdd8',
name: 'Test pilot',
address: '123 Any Street',
city: 'Any Town',
state: 'Minnesota',
postalCode: '55123',
email: 'user@example.com',
phone: '555-555-1234',
userId: 'user@example.com',
logs: [],
certificates: [],
endorsements: [],
medical: []
} as PilotEntity
jest.spyOn(mockPilotService, 'create').mockReturnValue(pilot);
const result = await controller.create(pilotDto);
expect(mockPilotService.create).toHaveBeenCalled();
expect(mockPilotService.create).toHaveBeenCalledWith(pilotDto)
expect(result).toEqual(pilot);
})
it('create => should fail to create a new pilot', async () => {
const pilotDto = {
name: 'Test pilot',
address: '123 Any Street',
city: 'Any Town',
state: 'Minnesota',
postalCode: '55123',
email: 'user@example.com',
phone: '555-555-1234'
} as PilotDto
jest.spyOn(mockPilotService, 'create').mockRejectedValue(new Error('Pilot failed to create'));
try {
await controller.create(pilotDto);
fail('create did not throw error')
} catch (error) {
expect(error).toBeInstanceOf(Error)
expect(mockPilotService.create).toHaveBeenCalled();
expect(mockPilotService.create).toHaveBeenCalledWith(pilotDto);
expect(mockPilotService.create).rejects.toThrow('Pilot failed to create')
}
})
it('update => should update an existing pilot', async () => {
const id: string = '39465ae3-7947-4cc2-b565-ca00a982fdd8'
const pilotDto = {
id: '39465ae3-7947-4cc2-b565-ca00a982fdd8',
name: 'Test pilot',
address: '123 Any Street',
city: 'Any Town',
state: 'Minnesota',
postalCode: '55123',
email: 'user@example.com',
phone: '555-555-1234'
} as PilotDto
jest.spyOn(mockPilotService, 'update').mockReturnValue(pilotDto);
const result = await controller.update(id, pilotDto);
expect(result).toEqual(pilotDto);
expect(mockPilotService.update).toHaveBeenCalled();
expect(mockPilotService.update).toHaveBeenCalledWith(id, pilotDto)
})
it('update => should fail to update an existing pilot', async () => {
const id: string = '39465ae3-7947-4cc2-b565-ca00a982fdd8'
const pilotDto = {
id: '39465ae3-7947-4cc2-b565-ca00a982fdd8',
name: 'Test pilot',
address: '123 Any Street',
city: 'Any Town',
state: 'Minnesota',
postalCode: '55123',
email: 'user@example.com',
phone: '555-555-1234'
} as PilotDto
jest.spyOn(mockPilotService, 'update').mockRejectedValue(new Error('Pilot failed to update'))
try {
await controller.update(id, pilotDto);
fail('update function did not throw error')
} catch (error) {
expect(error).toBeInstanceOf(Error)
expect(mockPilotService.update).toHaveBeenCalled();
expect(mockPilotService.update).toHaveBeenCalledWith(id, pilotDto);
expect(mockPilotService.update).rejects.toThrow('Pilot failed to update')
}
})
it('delete => should delete an existing pilot', async () => {
const id = '39465ae3-7947-4cc2-b565-ca00a982fdd8';
jest.spyOn(mockPilotService, 'delete');
const result = await controller.delete(id);
expect(mockPilotService.delete).toHaveBeenCalled();
expect(mockPilotService.delete).toHaveBeenCalledWith(id);
})
it('delete => should fail to delete an existing pilot', async () => {
const id = '39465ae3-7947-4cc2-b565-ca00a982fdd8';
jest.spyOn(mockPilotService, 'delete').mockRejectedValue(new Error('Pilot failed to delete'));
try {
await controller.delete(id);
fail('delete function did not throw error')
} catch (error) {
expect(error).toBeInstanceOf(Error)
expect(mockPilotService.delete).toHaveBeenCalled();
expect(mockPilotService.delete).toHaveBeenCalledWith(id);
expect(mockPilotService.delete).rejects.toThrow('Pilot failed to delete')
}
})
})

View File

@@ -43,20 +43,22 @@ export class PilotController {
return await this.pilotService.findAll();
} catch (error) {
const customError = error as CustomError;
console.log(error)
throw new HttpException(customError.message, customError.statusCode);
}
}
@Post()
@UseGuards(AuthGuard)
async create(@Body() pilotDto: PilotDto): Promise<PilotDto> {
async create(@Body() pilotDto: PilotDto) {
try {
return await this.pilotService.create(pilotDto);
} catch (error) {
const customError = error as CustomError;
console.log(error)
// const customError = error as CustomError;
throw new HttpException(customError.message, customError.statusCode);
// throw new HttpException(customError.message, customError.statusCode);
}
}

View File

@@ -1,3 +1,5 @@
import { LogEntity } from "src/log/log.entity";
export class PilotDto {
name: string;
address: string;

View File

@@ -1,8 +1,8 @@
import { LogEntity } from '../log/log.entity';
import { LogEntity } from 'src/log/log.entity';
import { Entity, PrimaryGeneratedColumn, Column, OneToMany } from 'typeorm';
import { CertificateEntity } from '../certificate/certificate.entity';
import { EndorsementEntity } from '../endorsement/endorsement.entity';
import { MedicalEntity } from '../medical/medical.entity';
import { EndorsementEntity } from 'src/endorsement/endorsement.entity';
import { MedicalEntity } from 'src/medical/medical.entity';
@Entity({ name: 'pilots' })
export class PilotEntity {

View File

@@ -1,6 +1,7 @@
import { CallHandler, ExecutionContext, NestInterceptor } from '@nestjs/common';
import { jwtDecode } from 'jwt-decode';
import { Observable, map } from 'rxjs';
import { CustomJwtPayload } from 'src/interfaces/customJwtPayload.interface';
import { PilotEntity } from './pilot.entity';
import { IS_PUBLIC_KEY } from '@noahspan/noahspan-modules';
import { Reflector } from '@nestjs/core';
@@ -25,15 +26,13 @@ export class PilotInterceptor implements NestInterceptor {
};
})
}
console.log(req.headers.authorization)
if (req.headers.authorization) {
const authHeader = req.headers.authorization;
const token = authHeader && authHeader.split(' ')[1];
const jwtPayload: CustomJwtPayload = jwtDecode(token);
const jwtPayload = jwtDecode(token);
const rolesKeyName = Object.keys(jwtPayload).find((key) => key.includes('roles'));
if (jwtPayload[rolesKeyName].includes('Flying.Read')) {
if (jwtPayload.roles.includes('Flying.Read')) {
const pilots = limitData(data)
return pilots;

View File

@@ -1,206 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { PilotService } from './pilot.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import { PilotEntity } from './pilot.entity';
import { PilotDto } from './pilot.dto';
import { CustomError } from '../error/customError';
describe('PilotService', () => {
let service: PilotService;
const mockPilotRepository = {
delete: jest.fn(),
find: jest.fn(),
findOneBy: jest.fn(),
save: jest.fn(),
}
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
PilotService,
{
provide: getRepositoryToken(PilotEntity),
useValue: mockPilotRepository
}
]
}).compile();
service = module.get<PilotService>(PilotService);
})
it('should be defined', () => {
expect(service).toBeDefined();
})
it('find => should find one pilot by id', async () => {
const id: string = '39465ae3-7947-4cc2-b565-ca00a982fdd8'
const pilot = {
id: '39465ae3-7947-4cc2-b565-ca00a982fdd8',
name: 'Test pilot',
address: '123 Any Street',
city: 'Any Town',
state: 'Minnesota',
postalCode: '55123',
email: 'user@example.com',
phone: '555-555-1234',
userId: 'user@example.com',
logs: [],
certificates: [],
endorsements: [],
medical: []
} as PilotEntity;
jest.spyOn(mockPilotRepository, 'findOneBy').mockReturnValue(pilot);
const result = await service.find(id);
expect(result).toEqual(pilot);
expect(mockPilotRepository.findOneBy).toHaveBeenCalled();
expect(mockPilotRepository.findOneBy).toHaveBeenCalledWith({ id: id });
})
it('find => should fail to find one pilot by id', async () => {
const id: string = '39465ae3-7947-4cc2-b565-ca00a982fdd8';
const mockCustomError = new CustomError('Pilot not found', 'Not found', 404)
jest.spyOn(mockPilotRepository, 'findOneBy').mockReturnValue(undefined);
try {
await service.find(id);
fail('find did not throw error')
} catch (error) {
expect(error).toBeInstanceOf(CustomError);
expect(mockPilotRepository.findOneBy).toHaveBeenCalled();
expect(mockPilotRepository.findOneBy).toHaveBeenCalledWith({ id: id });
}
})
it('findAll => should find all pilots', async () => {
const pilot = {
id: '39465ae3-7947-4cc2-b565-ca00a982fdd8',
name: 'Test pilot',
address: '123 Any Street',
city: 'Any Town',
state: 'Minnesota',
postalCode: '55123',
email: 'user@example.com',
phone: '555-555-1234',
userId: 'user@example.com',
logs: [],
certificates: [],
endorsements: [],
medical: []
} as PilotEntity;
const pilots = [pilot];
jest.spyOn(mockPilotRepository, 'find').mockReturnValue(pilots);
const result = await service.findAll();
expect(result).toEqual(pilots);
expect(mockPilotRepository.find).toHaveBeenCalled()
})
it('create => should create a new pilot', async () => {
const pilotDto = {
name: 'Test pilot',
address: '123 Any Street',
city: 'Any Town',
state: 'Minnesota',
postalCode: '55123',
email: 'user@example.com',
phone: '555-555-1234'
} as PilotDto
const pilot = {
id: '39465ae3-7947-4cc2-b565-ca00a982fdd8',
name: 'Test pilot',
address: '123 Any Street',
city: 'Any Town',
state: 'Minnesota',
postalCode: '55123',
email: 'user@example.com',
phone: '555-555-1234',
userId: 'user@example.com',
logs: [],
certificates: [],
endorsements: [],
medical: []
} as PilotEntity
jest.spyOn(mockPilotRepository, 'save').mockReturnValue(pilot);
const result = await service.create(pilotDto);
expect(mockPilotRepository.save).toHaveBeenCalled();
expect(mockPilotRepository.save).toHaveBeenCalledWith(pilotDto);
})
it('update => should update a pilot', async () => {
const id: string = '39465ae3-7947-4cc2-b565-ca00a982fdd8'
const pilotDto = {
name: 'Test pilot',
address: '123 Any Street',
city: 'Any Town',
state: 'Minnesota',
postalCode: '55123',
email: 'user@example.com',
phone: '555-555-1234'
} as PilotDto
const pilot = {
id: '39465ae3-7947-4cc2-b565-ca00a982fdd8',
name: 'Test pilot',
address: '123 Any Street',
city: 'Any Town',
state: 'Minnesota',
postalCode: '55123',
email: 'user@example.com',
phone: '555-555-1234',
userId: 'user@example.com',
logs: [],
certificates: [],
endorsements: [],
medical: []
} as PilotEntity
jest.spyOn(mockPilotRepository, 'findOneBy').mockReturnValue(pilot)
jest.spyOn(mockPilotRepository, 'save').mockReturnValue(pilotDto);
const result = await service.update(id, pilotDto)
expect(result).toEqual(pilotDto);
expect(mockPilotRepository.findOneBy).toHaveBeenCalled();
expect(mockPilotRepository.findOneBy).toHaveBeenCalledWith({ id: id });
expect(mockPilotRepository.save).toHaveBeenCalled();
expect(mockPilotRepository.save).toHaveBeenCalledWith(pilotDto)
})
it('delete => should delete a pilot', async () => {
const id: string = '39465ae3-7947-4cc2-b565-ca00a982fdd8'
const pilot = {
id: '39465ae3-7947-4cc2-b565-ca00a982fdd8',
name: 'Test pilot',
address: '123 Any Street',
city: 'Any Town',
state: 'Minnesota',
postalCode: '55123',
email: 'user@example.com',
phone: '555-555-1234',
userId: 'user@example.com',
logs: [],
certificates: [],
endorsements: [],
medical: []
} as PilotEntity
jest.spyOn(mockPilotRepository, 'delete').mockReturnValue(pilot);
const result = await service.delete(id);
expect(result).toEqual(pilot);
expect(mockPilotRepository.delete).toHaveBeenCalled();
expect(mockPilotRepository.delete).toHaveBeenCalledWith({ id: id });
})
})

View File

@@ -3,7 +3,7 @@ import { PilotEntity } from './pilot.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { DeleteResult, InsertResult, Repository, UpdateResult } from 'typeorm';
import { PilotDto } from './pilot.dto';
import { CustomError } from '../error/customError';
import { CustomError } from 'src/error/customError';
@Injectable()
export class PilotService {
@@ -13,7 +13,7 @@ export class PilotService {
async find(id: string): Promise<PilotEntity> {
try {
const pilotEntity: PilotEntity = await this.pilotRepository.findOneBy({ id });
const pilotEntity = await this.pilotRepository.findOneBy({ id });
if (pilotEntity) {
return pilotEntity
@@ -29,18 +29,15 @@ export class PilotService {
return await this.pilotRepository.find();
}
async create(pilot: PilotDto): Promise<PilotDto> {
return await this.pilotRepository.save(pilot);
async create(pilot: PilotDto): Promise<InsertResult> {
return await this.pilotRepository.insert(pilot);
}
async update(
id: string,
pilotDto: PilotDto
): Promise<PilotDto> {
const pilotEntity: PilotEntity = await this.pilotRepository.findOneBy({ id })
const pilotEntityUpdated = Object.assign(pilotEntity, pilotDto)
return await this.pilotRepository.save(pilotEntityUpdated);
pilot: PilotDto
): Promise<UpdateResult> {
return await this.pilotRepository.update(id, pilot);
}
async delete(id: string): Promise<DeleteResult> {

View File

@@ -1,218 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TrackController } from './track.controller';
import { TrackService } from './track.service';
import { TrackDto } from './track.dto';
import { TrackEntity } from './track.entity';
import { FileService } from '../file/file.service';
import { ConfigService } from '@nestjs/config';
import { HttpException } from '@nestjs/common';
import { Readable } from 'stream';
import { DeleteResult, InsertResult } from 'typeorm';
describe('TrackController', () => {
let controller: TrackController;
const mockTrackService = {
create: jest.fn(),
delete: jest.fn(),
downloadTrackFile: jest.fn(),
find: jest.fn(),
findAll: jest.fn(),
update: jest.fn()
}
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [TrackController],
providers: [
ConfigService,
FileService,
{
provide: TrackService,
useValue: mockTrackService
}
]
}).compile();
controller = module.get<TrackController>(TrackController);
})
it('should be defined', () => {
expect(controller).toBeDefined();
})
it('findAll => should find all tracks by log id', async () => {
const logId = '094ec69c-72c4-4995-8821-79d5b79bedda';
const track = {
id: 'a6973d91-629f-4e10-aa28-016076a21fde',
url: 'http://track.example.com',
order: 1
} as TrackEntity
const tracks = [track]
jest.spyOn(mockTrackService, 'findAll').mockReturnValue(tracks);
const result = await controller.findAll(logId);
expect(result).toEqual(tracks);
expect(mockTrackService.findAll).toHaveBeenCalled();
expect(mockTrackService.findAll).toHaveBeenLastCalledWith(logId);
})
it('findAll => should fail to find all tracks by log id', async () => {
const logId = '094ec69c-72c4-4995-8821-79d5b79bedda';
jest.spyOn(mockTrackService, 'findAll').mockRejectedValue(new Error('Tracks not found'))
try {
await controller.findAll(logId);
fail('findAll did not throw error');
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
expect(mockTrackService.findAll).toHaveBeenCalled();
expect(mockTrackService.findAll).rejects.toThrow('Tracks not found')
}
})
it('create => create a track by a log id', async () => {
const logId = '094ec69c-72c4-4995-8821-79d5b79bedda';
const order = 1;
const file: Express.Multer.File = {
fieldname: 'file',
originalname: 'test_track.kml',
encoding: 'utf-8',
mimetype: 'application/vnd.google-earth.kml+xml',
size: 12345,
destination: '/tmp/uploads',
filename: 'unique-filename-123.kml',
path: '/tmp/uploads/unique-filename-123.kml',
buffer: Buffer.from('<kml xmlns="http://www.opengis.net"></kml>'),
stream: new Readable()
}
const mockInsertResult: InsertResult = {
identifiers: [
{
id: 'a6973d91-629f-4e10-aa28-016076a21fde'
}
],
generatedMaps: [
{
id: 'a6973d91-629f-4e10-aa28-016076a21fde',
createdAd: new Date()
}
],
raw: {
affectedRows: 1
}
}
jest.spyOn(mockTrackService, 'create').mockReturnValue(mockInsertResult);
const result = await controller.create(logId, order, file);
expect(result).toEqual(mockInsertResult);
expect(mockTrackService.create).toHaveBeenCalled();
expect(mockTrackService.create).toHaveBeenCalledWith(logId, order, file)
})
it('create => should fail to create a track by log id', async () => {
const logId = '094ec69c-72c4-4995-8821-79d5b79bedda';
const order = 1;
const file: Express.Multer.File = {
fieldname: 'file',
originalname: 'test_track.kml',
encoding: 'utf-8',
mimetype: 'application/vnd.google-earth.kml+xml',
size: 12345,
destination: '/tmp/uploads',
filename: 'unique-filename-123.kml',
path: '/tmp/uploads/unique-filename-123.kml',
buffer: Buffer.from('<kml xmlns="http://www.opengis.net"></kml>'),
stream: new Readable()
}
jest.spyOn(mockTrackService, 'create').mockRejectedValue(new Error('Track failed to create'))
try {
await controller.create(logId, order, file)
fail('create did not throw error')
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
expect(mockTrackService.create).toHaveBeenCalled();
expect(mockTrackService.create).toHaveBeenCalledWith(logId, order, file);
expect(mockTrackService.create).rejects.toThrow('Track failed to create')
}
})
it('delete => should delete a track by log id', async () => {
const id = 'a6973d91-629f-4e10-aa28-016076a21fde'
const logId: string = '95834f84-0a02-44d3-884e-a20237adeca0';
const fileName = 'test_track.kml';
const mockDeleteResult: DeleteResult ={
raw: [],
affected: 1
}
jest.spyOn(mockTrackService, 'delete').mockReturnValue(mockDeleteResult);
const result = await controller.delete(id, fileName, logId);
expect(result).toEqual(mockDeleteResult);
expect(mockTrackService.delete).toHaveBeenCalled();
expect(mockTrackService.delete).toHaveBeenCalledWith(id, logId, fileName);
})
it('delete => should fail to delete a track by log id', async () => {
const id = 'a6973d91-629f-4e10-aa28-016076a21fde'
const logId: string = '95834f84-0a02-44d3-884e-a20237adeca0';
const fileName = 'test_track.kml';
jest.spyOn(mockTrackService, 'delete').mockRejectedValue(new Error('Track failed to delete'));
try {
await controller.delete(id, fileName, logId);
fail('delete did not throw error')
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
expect(mockTrackService.delete).toHaveBeenCalled();
expect(mockTrackService.delete).toHaveBeenCalledWith(id, logId, fileName);
expect(mockTrackService.delete).rejects.toThrow('Track failed to delete')
}
})
it('downloadTrack => should download a track by log id', async () => {
const logId: string = '95834f84-0a02-44d3-884e-a20237adeca0';
const fileName = 'test_track.kml';
const mockStreamToBufferString = 'fake-stream-to-buffer-string'
jest.spyOn(mockTrackService, 'downloadTrackFile').mockReturnValue(mockStreamToBufferString);
const result = await controller.downloadTrack(logId, fileName);
expect(result).toEqual(mockStreamToBufferString);
expect(mockTrackService.downloadTrackFile).toHaveBeenCalled();
expect(mockTrackService.downloadTrackFile).toHaveBeenCalledWith(logId, fileName)
})
it('downloadTrack => should fail to download a track by log id', async () => {
const logId: string = '95834f84-0a02-44d3-884e-a20237adeca0';
const fileName = 'test_track.kml';
jest.spyOn(mockTrackService, 'downloadTrackFile').mockRejectedValue(new Error('Track failed to download'));
try {
await controller.downloadTrack(logId, fileName);
fail('downloadTrack did not throw error')
} catch (error) {
expect(error).toBeInstanceOf(HttpException);
expect(mockTrackService.downloadTrackFile).toHaveBeenCalled();
expect(mockTrackService.downloadTrackFile).toHaveBeenCalledWith(logId, fileName);
expect(mockTrackService.downloadTrackFile).rejects.toThrow('Track failed to download')
}
})
})

View File

@@ -1,4 +1,4 @@
import { LogEntity } from '../log/log.entity';
import { LogEntity } from 'src/log/log.entity';
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn } from 'typeorm';
@Entity({ name: 'tracks' })

View File

@@ -4,7 +4,7 @@ import { TrackEntity } from "./track.entity";
import { TrackController } from "./track.controller";
import { FileService } from "../file/file.service";
import { TrackService } from './track.service';
import { LogModule } from '../log/log.module';
import { LogModule } from 'src/log/log.module';
@Module({
imports: [

View File

@@ -1,346 +0,0 @@
import { Test, TestingModule } from '@nestjs/testing';
import { TrackService } from './track.service';
import { getRepositoryToken } from '@nestjs/typeorm';
import { TrackEntity } from './track.entity'
import { TrackDto } from './track.dto';
import { FileService } from '../file/file.service';
import { ConfigService } from '@nestjs/config';
import { LogService } from '../log/log.service';
import { LogEntity } from '../log/log.entity';
import { PilotService } from '../pilot/pilot.service';
import { PilotEntity } from '../pilot/pilot.entity';
import { Readable } from 'stream';
import { DeleteResult, InsertResult, UpdateResult } from 'typeorm';
import { CustomError } from '../error/customError';
describe('TrackService', () => {
let service: TrackService;
const mockFileService = {
deleteFile: jest.fn(),
downloadFile: jest.fn(),
uploadFile: jest.fn()
}
const mockLogService = {
create: jest.fn(),
delete: jest.fn(),
find: jest.fn(),
findAll: jest.fn(),
update: jest.fn()
}
const mockPilotRepository = {
create: jest.fn(),
delete: jest.fn(),
find: jest.fn(),
findAll: jest.fn(),
update: jest.fn()
}
const mockTrackRepository = {
create: jest.fn(),
delete: jest.fn(),
downloadTrackFile: jest.fn(),
findOneBy: jest.fn(),
find: jest.fn(),
insert: jest.fn(),
update: jest.fn()
}
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
ConfigService,
LogService,
PilotService,
TrackService,
{
provide: FileService,
useValue: mockFileService
},
{
provide: LogService,
useValue: mockLogService
},
{
provide: getRepositoryToken(PilotEntity),
useValue: mockPilotRepository
},
{
provide: getRepositoryToken(TrackEntity),
useValue: mockTrackRepository
}
]
}).compile()
service = module.get<TrackService>(TrackService);
})
it('should be defined', () => {
expect(service).toBeDefined();
})
it('create => should upload a track file', async () => {
const logId = '094ec69c-72c4-4995-8821-79d5b79bedda';
const log = {
id: '094ec69c-72c4-4995-8821-79d5b79bedda',
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
date: new Date('2026-02-21'),
aircraftMakeModel: 'Cessna 172M',
aircraftIdentity: 'N12345',
routeFrom: 'KMSP',
routeTo: 'KMSP',
durationOfFlight: 1,
singleEngineLand: 1,
simulatorAtd: null,
landingsDay: 1,
landingsNight: null,
groundTrainingReceived: null,
flightTrainingReceived: null,
crossCountry: 1,
night: null,
solo: 1,
pilotInCommand: 1,
instrumentActual: null,
instrumentSimulated: null,
instrumentApproaches: null,
instrumentHolds: null,
instrumentNavTrack: null,
notes: 'This is a test',
tracks: []
} as LogEntity;
const order = 1;
const file: Express.Multer.File = {
fieldname: 'file',
originalname: 'test_track.kml',
encoding: 'utf-8',
mimetype: 'application/vnd.google-earth.kml+xml',
size: 12345,
destination: '/tmp/uploads',
filename: 'unique-filename-123.kml',
path: '/tmp/uploads/unique-filename-123.kml',
buffer: Buffer.from('<kml xmlns="http://www.opengis.net"></kml>'),
stream: new Readable()
}
const mockInsertResult: InsertResult = {
identifiers: [
{
id: 'a6973d91-629f-4e10-aa28-016076a21fde'
}
],
generatedMaps: [
{
id: 'a6973d91-629f-4e10-aa28-016076a21fde',
createdAd: new Date()
}
],
raw: {
affectedRows: 1
}
}
const track = {
id: 'a6973d91-629f-4e10-aa28-016076a21fde',
url: 'http://track.example.com',
order: 1
} as TrackEntity
jest.spyOn(mockLogService, 'find').mockReturnValue(log)
jest.spyOn(mockTrackRepository, 'create').mockReturnValue(track)
jest.spyOn(mockTrackRepository, 'insert').mockReturnValue(mockInsertResult);
const result = await service.create(logId, order, file);
expect(result).toEqual(mockInsertResult)
expect(mockTrackRepository.insert).toHaveBeenCalled();
expect(mockTrackRepository.insert).toHaveBeenCalledWith({
id: track.id,
order: track.order,
url: track.url
})
})
it('create => should fail to find log', async () => {
const logId = '094ec69c-72c4-4995-8821-79d5b79bedda';
const order = 1;
const file: Express.Multer.File = {
fieldname: 'file',
originalname: 'test_track.kml',
encoding: 'utf-8',
mimetype: 'application/vnd.google-earth.kml+xml',
size: 12345,
destination: '/tmp/uploads',
filename: 'unique-filename-123.kml',
path: '/tmp/uploads/unique-filename-123.kml',
buffer: Buffer.from('<kml xmlns="http://www.opengis.net"></kml>'),
stream: new Readable()
}
jest.spyOn(mockLogService, 'find').mockReturnValue(undefined);
try {
await service.create(logId, order, file);
fail('find failed to throw error')
} catch (error) {
expect(error).toBeInstanceOf(CustomError);
expect(mockLogService.find).toHaveBeenCalled();
expect(mockLogService.find).toHaveBeenCalledWith(logId);
}
})
it('delete => should delete a track file', async () => {
const id = 'a6973d91-629f-4e10-aa28-016076a21fde';
const logId = '094ec69c-72c4-4995-8821-79d5b79bedda';
const fileName = 'test_track.kml'
const mockDeleteResult: DeleteResult ={
raw: [],
affected: 1
}
jest.spyOn(mockTrackRepository, 'delete').mockReturnValue(mockDeleteResult);
const result = await service.delete(id, logId, fileName)
expect(result).toEqual(mockDeleteResult);
expect(mockTrackRepository.delete).toHaveBeenCalled();
expect(mockTrackRepository.delete).toHaveBeenCalledWith({ id: id })
})
it('delete => should fail to delete file in file service', async () => {
const id = 'a6973d91-629f-4e10-aa28-016076a21fde';
const logId = '094ec69c-72c4-4995-8821-79d5b79bedda';
const fileName = 'test_track.kml'
jest.spyOn(mockFileService, 'deleteFile').mockRejectedValue(new Error('Failed to delete file'))
try {
await service.delete(id, logId, fileName)
} catch (error) {
expect(error).toBeInstanceOf(Error);
expect(mockFileService.deleteFile).toHaveBeenCalled();
expect(mockFileService.deleteFile).toHaveBeenCalledWith('tracks', logId, fileName)
expect(mockFileService.deleteFile).rejects.toThrow('Failed to delete file')
}
})
it('downloadTrackFile => should download a track file by log id and filename', async () => {
const logId: string = '95834f84-0a02-44d3-884e-a20237adeca0';
const fileName = 'test_track.kml';
const mockStreamToBufferString = 'fake-stream-to-buffer-string'
jest.spyOn(mockFileService, 'downloadFile').mockReturnValue(mockStreamToBufferString);
const result = await service.downloadTrackFile(logId, fileName);
expect(result).toEqual(mockStreamToBufferString);
expect(mockFileService.downloadFile).toHaveBeenCalled();
expect(mockFileService.downloadFile).toHaveBeenCalledWith('tracks', logId, fileName);
})
it('find => should find a track file by id', async () => {
const id = 'a6973d91-629f-4e10-aa28-016076a21fde';
const track = {
id: 'a6973d91-629f-4e10-aa28-016076a21fde',
url: 'http://track.example.com',
order: 1
} as TrackEntity
jest.spyOn(mockTrackRepository, 'findOneBy').mockReturnValue(track)
const result = await service.find(id);
expect(result).toEqual(track);
expect(mockTrackRepository.findOneBy).toHaveBeenCalled();
expect(mockTrackRepository.findOneBy).toHaveBeenCalledWith({ id: id })
})
it('findAll => should find all track files by log id', async () => {
const logId = '094ec69c-72c4-4995-8821-79d5b79bedda';
const log = {
id: '094ec69c-72c4-4995-8821-79d5b79bedda',
pilotId: '7c4bae6b-9ec4-469e-8e16-fcbf0b940936',
date: new Date('2026-02-21'),
aircraftMakeModel: 'Cessna 172M',
aircraftIdentity: 'N12345',
routeFrom: 'KMSP',
routeTo: 'KMSP',
durationOfFlight: 1,
singleEngineLand: 1,
simulatorAtd: null,
landingsDay: 1,
landingsNight: null,
groundTrainingReceived: null,
flightTrainingReceived: null,
crossCountry: 1,
night: null,
solo: 1,
pilotInCommand: 1,
instrumentActual: null,
instrumentSimulated: null,
instrumentApproaches: null,
instrumentHolds: null,
instrumentNavTrack: null,
notes: 'This is a test',
tracks: []
} as LogEntity;
const track = {
id: 'a6973d91-629f-4e10-aa28-016076a21fde',
url: 'http://track.example.com',
order: 1
} as TrackEntity;
const tracks = [track]
jest.spyOn(mockLogService, 'find').mockReturnValue(log)
jest.spyOn(mockTrackRepository, 'find').mockReturnValue(tracks);
const result = await service.findAll(logId);
expect(result).toEqual(tracks);
expect(mockTrackRepository.find).toHaveBeenCalled();
expect(mockTrackRepository.find).toHaveBeenCalledWith(
{
where: {
log: {
id: logId
}
}
})
})
it('findAll => should fail to find log', async () => {
const logId = '094ec69c-72c4-4995-8821-79d5b79bedda';
jest.spyOn(mockLogService, 'find').mockReturnValue(undefined);
try {
await service.findAll(logId);
fail('find failed to throw error')
} catch (error) {
expect(error).toBeInstanceOf(CustomError);
expect(mockLogService.find).toHaveBeenCalled();
expect(mockLogService.find).toHaveBeenCalledWith(logId);
}
})
it('update => should update a track', async () => {
const id = 'a6973d91-629f-4e10-aa28-016076a21fde';
const track = {
} as TrackDto;
const mockUpdateResult: UpdateResult = {
affected: 1,
raw: [],
generatedMaps: []
}
jest.spyOn(mockTrackRepository, 'update').mockReturnValue(mockUpdateResult)
const result = await service.update(id, track);
expect(result).toEqual(mockUpdateResult);
expect(mockTrackRepository.update).toHaveBeenCalled();
expect(mockTrackRepository.update).toHaveBeenCalledWith(id, track)
})
})

View File

@@ -3,11 +3,10 @@ import { TrackDto } from "./track.dto";
import { TrackEntity } from "./track.entity";
import { InjectRepository } from "@nestjs/typeorm";
import { Injectable } from "@nestjs/common";
import { LogService } from "../log/log.service";
import { LogEntity } from "../log/log.entity";
import { CustomError } from "../error/customError";
import { FileService } from "../file/file.service";
import { Logs } from "src/log/logs.interface";
import { LogService } from "src/log/log.service";
import { LogEntity } from "src/log/log.entity";
import { CustomError } from "src/error/customError";
import { FileService } from "src/file/file.service";
@Injectable()
export class TrackService {
@@ -20,7 +19,11 @@ export class TrackService {
) {}
async find(id: string): Promise<TrackEntity> {
return await this.trackRepository.findOneBy({ id });
try {
return await this.trackRepository.findOneBy({ id });
} catch (error) {
throw new CustomError('Track not found', 'Not found', 404)
}
}
async findAll(logId: string): Promise<TrackEntity[]> {
@@ -37,11 +40,10 @@ export class TrackService {
})
return tracks;
} else {
throw new CustomError('Tracks not found', 'Not found', 404);
}
} catch (error) {
throw error
console.log(error)
throw new CustomError('Tracks not found', 'Not found', 404);
}
}
@@ -67,11 +69,21 @@ export class TrackService {
}
async update(id: string, track: TrackDto): Promise<UpdateResult> {
return await this.trackRepository.update(id, track);
try {
return await this.trackRepository.update(id, track);
} catch(error) {
throw error
}
}
async downloadTrackFile(logId: string, fileName: string): Promise<string> {
return await this.fileService.downloadFile(this.containerName, logId, fileName);
try {
const downloadedFile: string = await this.fileService.downloadFile(this.containerName, logId, fileName);
return downloadedFile;
} catch (error) {
throw error
}
}
async delete(id: string, logId: string, fileName: string): Promise<DeleteResult> {

View File

@@ -1,9 +0,0 @@
export class ColumnNumericTransformer {
to(data: number): number {
return data;
}
from (data: string): number {
return parseFloat(data);
}
}

View File

@@ -1 +0,0 @@
...

View File

@@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "2.1.5",
"version": "2.0.0-alpha-1",
"type": "module",
"scripts": {
"dev": "vite",
@@ -14,10 +14,11 @@
"@fortawesome/fontawesome-svg-core": "^7.1.0",
"@fortawesome/free-solid-svg-icons": "^7.1.0",
"@fortawesome/react-fontawesome": "^3.1.0",
"@tailwindcss/typography": "^0.5.19",
"@noahspan/noahspan-components": "^2.0.0-alpha-14",
"@tailwindcss/vite": "^4.1.13",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.7.2",
"daisyui": "^5.1.10",
"dotenv": "^16.4.7",
"leaflet": "^1.9.4",
"oidc-spa": "^7.2.4",

View File

@@ -1,22 +1,24 @@
import { Route, Routes } from 'react-router-dom';
import { Navigate, Route, Routes } from 'react-router-dom';
import Flights from './components/flights/Flights';
import Logbook from './components/logbook/Logbook';
import Pilots from './components/pilots/Pilots';
import SiteNav from './components/siteNav/SiteNav';
import { useNavigate } from 'react-router-dom';
import { HeroUIProvider } from '@heroui/react';
import { useHref, useNavigate } from 'react-router-dom';
import './styles.css';
const App = () => {
const navigate = useNavigate();
return (
<div className='bg-[#f5f5f5]' data-theme="lofi">
<HeroUIProvider navigate={navigate} useHref={useHref}>
<SiteNav />
<Routes>
<Route path='/' element={<Flights />} />
<Route path="/logbook" element={<Logbook />} />
<Route path="/pilots" element={<Pilots />} />
</Routes>
</div>
</HeroUIProvider>
);
};

View File

@@ -1,11 +1,11 @@
import { createReactOidc } from "oidc-spa/react";
export const { OidcProvider, useOidc, getOidc } = createReactOidc(async () => ({
issuerUri: import.meta.env.VITE_ISSUER_URI,
clientId: import.meta.env.VITE_CLIENT_ID,
homeUrl: import.meta.env.VITE_BASE_URL,
scopes: ['email', 'openid', 'profile', `api://${import.meta.env.VITE_CLIENT_ID}/user_impersonation`],
issuerUri: `https://login.microsoftonline.com/${import.meta.env.VITE_TENANT_ID}/v2.0`,
clientId: import.meta.env.VITE_CLIENT_APP_ID,
homeUrl: import.meta.env.BASE_URL,
scopes: ['email', 'openid', 'profile', `api://${import.meta.env.VITE_API_APP_ID}/user_impersonation`],
autoLogin: false,
postLoginRedirectUrl: '/',
noIframe: true,
noIframe: true
}));

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

@@ -1,44 +0,0 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { AlertProps } from "./AlertProps.interface";
import { faCircleCheck, faCircleInfo, faCircleXmark, faTriangleExclamation, faXmark } from "@fortawesome/free-solid-svg-icons";
const Alert = ({
children,
className,
closeIcon,
severity,
onClose,
...rest
}: AlertProps) => {
const severityVariants = {
info: 'alert-info',
error: 'alert-error',
success: 'alert-success',
warning: 'alert-warning'
};
return (
<>
<div
role='alert'
className={`alert ${severity ? severityVariants[severity] : ''} ${className ? className : ''}`}
{...rest}
>
<span>
{severity === 'info' && <FontAwesomeIcon icon={faCircleInfo} />}
{severity === 'error' && <FontAwesomeIcon icon={faCircleXmark} />}
{severity === 'success' && (
<FontAwesomeIcon icon={faCircleCheck} />
)}
{severity === 'warning' && (
<FontAwesomeIcon icon={faTriangleExclamation} />
)}
</span>
<span>{children}</span>
{closeIcon && <button className='btn' onClick={onClose}>{<FontAwesomeIcon icon={faXmark} />}</button>}
</div>
</>
);
};
export default Alert;

View File

@@ -1,7 +0,0 @@
export interface AlertProps {
children?: React.ReactNode;
className?: string;
closeIcon?: React.ReactNode;
onClose?: () => void;
severity: 'info' | 'error' | 'success' | 'warning';
}

View File

@@ -1,3 +1,13 @@
// 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';
@@ -11,25 +21,31 @@ const ConfirmationDialog = ({
title
}: DialogConfirmationProps) => {
return (
<>
<input type='checkbox' id='dialog' className='modal-toggle' onChange={() => {}} checked={isOpen} />
<div className='modal' role='dialog'>
<div className='modal-box'>
<h3 className="text-lg font-bold">{title}</h3>
<p className="py-4">{contentText}</p>
<div className='modal-action'>
<button className='btn' onClick={onCancel}>
<FontAwesomeIcon icon={faXmark} />
No
</button>
<button className='btn btn-primary' onClick={onConfirm}>
<FontAwesomeIcon icon={faCircleCheck} />
Yes
</button>
</div>
</div>
</div>
</>
<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>
);
};

View File

@@ -1,104 +1,76 @@
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 { useOidc } from "../../auth/oidcConfig";
import Alert from "../alert/Alert";
import { AxiosError, AxiosResponse } from "axios";
import httpClient from "../../httpClient/httpClient";
import { useBreakpoints } from "../../hooks/useBreakpoints/UseBreakpoints";
import { ScreenSize } from "../../enums/screenSize";
import { Alert } from '@heroui/react'
const Flights = () => {
const [state, dispatch] = useReducer(reducer, initialState);
const { isUserLoggedIn } = useOidc();
const { screenSize } = useBreakpoints();
const getFlights = async (pageIndex: number, pageSize: number) => {
try {
const response: AxiosResponse = await httpClient.get(`api/logs/flights`, {
params: {
skip: pageIndex,
take: pageSize
}
});
const flights: LogbookEntry[] = response.data.entities;
const hasMore: boolean = response.data.hasNextPage;
const total: number = response.data.total;
if (flights.length > 0) {
const newFlights: LogbookEntry[] = [...state.flights, ...flights]
const newPageIndex: number = state.pageIndex + flights.length;
dispatch({ type: 'SET_FLIGHTS', payload: { flights: newFlights, hasMoreFlights: hasMore, pageIndex: newPageIndex, totalFlights: total }})
if (!isUserLoggedIn && flights.length >= 5) {
dispatch({ type: 'SET_ALERT', payload: { severity: 'info', message: 'A limited number of flights displayed. Sign in to view all flights.'}})
} else {
dispatch({ type: 'SET_ALERT', payload: undefined })
}
} else {
dispatch({ type: 'SET_ALERT', payload: { severity: 'info', message: 'No flights found' }})
}
} catch (error) {
const axiosError = error as AxiosError;
dispatch({
type: 'SET_ALERT',
payload: { severity: 'error', message: `Loading of flights failed with the following message: ${axiosError.message}`}
});
} finally {
dispatch({ type: 'SET_IS_LOADING', payload: false })
}
}
const loadMore = () => {
getFlights(state.pageIndex, state.pageSize)
}
const { logs, logsLoading } = useLogs();
useEffect(() => {
getFlights(state.pageIndex, state.pageSize);
}, [])
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 ${screenSize === ScreenSize.SM || screenSize === ScreenSize.MD? 'mr-4 ml-4' : ''}`}>
<div className='max-w-screen-lg mx-auto'>
<div className='prose mt-5 mb-5'>
<h1>Flights</h1>
</div>
{!state.isLoading && state.alert && (
{!logsLoading && state.alert && (
<div>
<Alert
className='mb-5'
onClose={() =>
dispatch({ type: 'SET_ALERT', payload: undefined })
}
severity={state.alert.severity}
>
{state.alert.message}
</Alert>
color={state.alert.severity}
title={state.alert.message}
/>
</div>
)}
{!state.isLoading &&
{!logsLoading &&
<div>
<div>
<LogbookCard logs={state.flights} mode='flights' />
</div>
{state.hasMoreFlights &&
<div className='flex items-center justify-center mb-5'>
<button className='btn btn-link' onClick={loadMore}>Load more</button>
</div>
}
<LogbookCard logs={state.flights} mode='flights' />
</div>
}
{state.isLoading && [...Array(5)].map((_element, index) => {
{logsLoading && [...Array(6)].map((_element, index) => {
return (
<div className='card bg-base-100 border border-base-300 p-2 mb-5'>
<div className='card-body' key={index}>
<div className='skeleton h-10 w-[150px]' />
<div className='skeleton h-[350px]' />
<div className='skeleton h-[65px]' />
</div>
<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>
)
})}

View File

@@ -5,8 +5,4 @@ export interface FlightsState {
alert: Alert | undefined;
flights: LogbookEntry[];
isLoading: boolean;
hasMoreFlights: boolean;
pageIndex: number;
pageSize: number
totalFlights: number;
}

View File

@@ -5,17 +5,13 @@ import { FlightsState } from "./FlightsState.interface";
type Action =
| { type: 'SET_ALERT'; payload: Alert | undefined }
| { type: 'SET_FLIGHTS'; payload: { flights: LogbookEntry[], hasMoreFlights: boolean, pageIndex: number, totalFlights: number } }
| { type: 'SET_FLIGHTS'; payload: LogbookEntry[] }
| { type: 'SET_IS_LOADING'; payload: boolean }
export const initialState: FlightsState = {
alert: undefined,
flights: [],
isLoading: true,
hasMoreFlights: true,
pageIndex: 0,
pageSize: 5,
totalFlights: 0
isLoading: true
}
export const reducer = (
@@ -32,10 +28,7 @@ export const reducer = (
case 'SET_FLIGHTS': {
return {
...state,
flights: action.payload.flights,
hasMoreFlights: action.payload.hasMoreFlights,
pageIndex: action.payload.pageIndex,
totalFlights: action.payload.totalFlights
flights: action.payload
}
}
case 'SET_IS_LOADING': {

View File

@@ -1,5 +1,6 @@
import { useEffect, useReducer } from 'react';
import { useForm, Controller, useFormContext } from 'react-hook-form';
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';
@@ -9,6 +10,7 @@ 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 = () => {
@@ -38,7 +40,7 @@ const LogForm = () => {
} catch (error) {
const axiosError = error as AxiosError;
dispatch({ type: 'SET_ALERT', payload: { severity: 'error', message: axiosError.message }});
dispatch({ type: 'SET_ALERT', payload: { severity: 'danger', message: axiosError.message }});
} finally {
dispatch({ type: 'SET_IS_LOADING', payload: false });
}
@@ -53,16 +55,11 @@ const LogForm = () => {
if (pilots && FormMode.ADD) {
const newPilotsOptions = pilots.map((pilot) => {
return {
key: pilot.id,
label: pilot.name,
value: pilot.id,
};
});
newPilotsOptions.unshift({
label: '',
value: '',
})
console.log(newPilotsOptions)
dispatch({ type: 'SET_PILOT_OPTIONS', payload: newPilotsOptions });
}
}, [pilots]);
@@ -76,21 +73,25 @@ const LogForm = () => {
<Controller
name="pilotId"
control={control}
render={({ field: { onChange, value } }) => {
render={({ field: { value } }) => {
return (
<select
className='select w-full'
<Select
aria-labelledby='pilot'
disabled={state.isDisabled}
onChange={onChange}
value={[value]}
isDisabled={state.isDisabled}
fullWidth={true}
isRequired={true}
onSelectionChange={(keys: SharedSelection) => {
setValue('pilotId', keys.currentKey);
}}
selectedKeys={[value]}
size='lg'
>
{state.pilotOptions?.map((pilotOption: { label: string, value: string; }) => {
{state.pilotOptions?.map((pilotOption: { key: string; label: string, }) => {
return (
<option value={pilotOption.value}>{pilotOption.label}</option>
<SelectItem key={pilotOption.key}>{pilotOption.label}</SelectItem>
)
})}
</select>
</Select>
);
}}
/>
@@ -103,14 +104,21 @@ const LogForm = () => {
name="date"
control={control}
render={({ field: { onChange, value } }) => {
const parsedAbsoluteDate = value ? parseAbsolute(value, getLocalTimeZone()) : value
return(
<input
type='date'
className='input w-full'
disabled={state.isDisabled}
onChange={onChange}
value={value ? value.split('T')[0] : ''}
/>
<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}
/>
)
}}
/>
@@ -123,11 +131,18 @@ const LogForm = () => {
name="aircraftMakeModel"
control={control}
render={({ field: { onChange, value } }) => (
<input
type='text'
className='input w-full'
disabled={state.isDisabled}
<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}
/>
)}
@@ -143,11 +158,18 @@ const LogForm = () => {
name="aircraftIdentity"
control={control}
render={({ field: { onChange, value } }) => (
<input
type='text'
className='input w-full'
disabled={state.isDisabled}
<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}
/>
)}
@@ -163,11 +185,18 @@ const LogForm = () => {
name="routeFrom"
control={control}
render={({ field: { onChange, value } }) => (
<input
type='text'
className='input w-full'
disabled={state.isDisabled}
<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}
/>
)}
@@ -181,11 +210,18 @@ const LogForm = () => {
name="routeTo"
control={control}
render={({ field: { onChange, value } }) => (
<input
type='text'
className='input w-full'
disabled={state.isDisabled}
<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}
/>
)}
@@ -199,11 +235,21 @@ const LogForm = () => {
name="durationOfFlight"
control={control}
render={({ field: { onChange, value } }) => (
<input
type='number'
className='input w-full'
disabled={state.isDisabled}
<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}
/>
)}
@@ -219,11 +265,21 @@ const LogForm = () => {
name="singleEngineLand"
control={control}
render={({ field: { onChange, value } }) => (
<input
type='number'
className='input w-full'
disabled={state.isDisabled}
<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}
/>
)}
@@ -237,11 +293,20 @@ const LogForm = () => {
name="simulatorAtd"
control={control}
render={({ field: { onChange, value } }) => (
<input
type='number'
className='input w-full'
disabled={state.isDisabled}
<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}
/>
)}
@@ -258,11 +323,22 @@ const LogForm = () => {
name="landingsDay"
control={control}
render={({ field: { onChange, value } }) => (
<input
type='number'
className='input w-full'
disabled={state.isDisabled}
<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}
/>
)}
@@ -276,11 +352,22 @@ const LogForm = () => {
name="landingsNight"
control={control}
render={({ field: { onChange, value } }) => (
<input
type='number'
className='input w-full'
disabled={state.isDisabled}
<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}
/>
)}
@@ -299,12 +386,24 @@ const LogForm = () => {
name="groundTrainingReceived"
control={control}
render={({ field: { onChange, value } }) => (
<input
type='number'
className='input w-full'
disabled={state.isDisabled}
<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'
/>
)}
/>
@@ -319,11 +418,22 @@ const LogForm = () => {
name="flightTrainingReceived"
control={control}
render={({ field: { onChange, value } }) => (
<input
type='number'
className='input w-full'
disabled={state.isDisabled}
<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}
/>
)}
@@ -337,11 +447,22 @@ const LogForm = () => {
name="crossCountry"
control={control}
render={({ field: { onChange, value } }) => (
<input
type='number'
className='input w-full'
disabled={state.isDisabled}
<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}
/>
)}
@@ -355,11 +476,22 @@ const LogForm = () => {
name="night"
control={control}
render={({ field: { onChange, value } }) => (
<input
type='number'
className='input w-full'
disabled={state.isDisabled}
<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}
/>
)}
@@ -373,11 +505,22 @@ const LogForm = () => {
name="solo"
control={control}
render={({ field: { onChange, value } }) => (
<input
type='number'
className='input w-full'
disabled={state.isDisabled}
<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}
/>
)}
@@ -391,11 +534,22 @@ const LogForm = () => {
name="pilotInCommand"
control={control}
render={({ field: { onChange, value } }) => (
<input
type='number'
className='input w-full'
disabled={state.isDisabled}
<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}
/>
)}
@@ -412,11 +566,22 @@ const LogForm = () => {
name="instrumentActual"
control={control}
render={({ field: { onChange, value } }) => (
<input
type='number'
className='input w-full'
disabled={state.isDisabled}
<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}
/>
)}
@@ -430,11 +595,22 @@ const LogForm = () => {
name="instrumentSimulated"
control={control}
render={({ field: { onChange, value } }) => (
<input
type='number'
className='input w-full'
disabled={state.isDisabled}
<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}
/>
)}
@@ -450,11 +626,22 @@ const LogForm = () => {
name="instrumentApproaches"
control={control}
render={({ field: { onChange, value } }) => (
<input
type='number'
className='input w-full'
disabled={state.isDisabled}
<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}
/>
)}
@@ -468,11 +655,22 @@ const LogForm = () => {
name="instrumentHolds"
control={control}
render={({ field: { onChange, value } }) => (
<input
type='number'
className='input w-full'
disabled={state.isDisabled}
<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}
/>
)}
@@ -486,13 +684,23 @@ const LogForm = () => {
name="instrumentNavTrack"
control={control}
render={({ field: { onChange, value } }) => (
<input
type='number'
className='input w-full'
disabled={state.isDisabled}
min={0}
onChange={(event) => onChange(Number(event.target.value))}
step={0.1}
<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}
/>
)}
@@ -508,12 +716,19 @@ const LogForm = () => {
name="notes"
control={control}
render={({ field: { onChange, value } }) => (
<textarea
className='textarea w-full'
disabled={state.isDisabled}
<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}
></textarea>
/>
)}
/>
</div>

View File

@@ -1,9 +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;
pilotOptions: { label: string; value: string; }[];
landingsSelectedKeys: Selection;
pilotOptions: { key: string; label: string; }[];
selectedPilotName: string;
}

View File

@@ -1,17 +1,24 @@
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_PILOT_OPTIONS'; payload: { label: string; value: string }[] }
| { 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: ''
};
@@ -27,18 +34,36 @@ export const reducer = (
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,

View File

@@ -1,7 +1,10 @@
import { useEffect, useReducer, useState } from 'react';
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';
@@ -11,42 +14,21 @@ 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, faAngleLeft, faAngleRight, faAnglesLeft, faAnglesRight } from '@fortawesome/free-solid-svg-icons'
import { CellContext, ColumnDef, flexRender, getCoreRowModel, HeaderContext, PaginationState, useReactTable } from '@tanstack/react-table';
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';
import Alert from '../alert/Alert';
import TrackMap from '../trackMap/TrackMap';
interface ActionsProps {
id: string;
}
const Logbook: React.FC<unknown> = () => {
const [state, dispatch] = useReducer(reducer, initialState);
const [columnVisibility, setColumnVisibility] = useState({});
const logbookContext = useLogbookContext();
const logbookContext = useLogbookContext()
const { isUserLoggedIn } = useOidc();
const { userRole } = useUserRole();
const { screenSize } = useBreakpoints();
const Actions = ({ id }: ActionsProps) => {
return (
<div className='dropdown dropdown-end'>
<div tabIndex={0} role='button' className='btn btn-ghost p-0'><FontAwesomeIcon icon={faEllipsisVertical} /></div>
<ul tabIndex={-1} className="dropdown-content menu bg-base-100 rounded-box w-52 p-2 shadow-sm border border-base-300 !z-[100]">
{isUserLoggedIn && userRole === UserRole.WRITE &&
<li><a onClick={() => onOpenCloseDrawer(FormMode.EDIT, id)}><FontAwesomeIcon icon={faPen} />Edit</a></li>
}
<li><a onClick={() => onOpenCloseDrawer(FormMode.VIEW, id)}><FontAwesomeIcon icon={faEye} />View</a></li>
{isUserLoggedIn && userRole === UserRole.WRITE &&
<li><a onClick={() => onDeleteLog(id)}><FontAwesomeIcon icon={faTrash} />Delete</a></li>
}
</ul>
</div>
)
}
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;
@@ -72,7 +54,7 @@ const Logbook: React.FC<unknown> = () => {
accessorKey: 'date',
header: 'Date',
cell: (info: CellContext<LogbookEntry, unknown>) => {
const date = new Date((info.getValue() as string).replace('Z', ''));
const date = new Date(info.getValue() as string);
const formattedDate = `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`
return formattedDate;
@@ -87,17 +69,13 @@ const Logbook: React.FC<unknown> = () => {
id: 'route',
header: 'Route of Flight',
meta: {
className: 'border-l border-base-300',
headerAlign: 'text-center'
headerAlign: 'center'
},
columns: [
{
id: 'routeFrom',
accessorKey: 'routeFrom',
header: 'From',
meta: {
className: 'border-l border-base-300',
}
header: 'From'
},
{
id: 'routeTo',
@@ -111,9 +89,8 @@ const Logbook: React.FC<unknown> = () => {
accessorKey: 'durationOfFlight',
header: 'Duration Of Flight',
meta: {
align: 'text-right',
className: 'border-l border-base-300',
headerAlign: 'text-right'
align: 'right',
headerAlign: 'right'
},
cell: (info: CellContext<LogbookEntry, unknown>) =>
info.getValue() ? parseFloat(info.getValue() as string).toFixed(1) : '',
@@ -122,27 +99,65 @@ const Logbook: React.FC<unknown> = () => {
const notes: ColumnDef<LogbookEntry> = {
id: 'notes',
accessorKey: 'notes',
header: 'Notes',
meta: {
className: 'border-l border-base-300',
}
header: 'Notes'
}
const actions: ColumnDef<LogbookEntry> = {
id: 'actions',
header: 'Actions',
meta: {
align: 'text-center',
className: 'border-l border-base-300',
headerAlign: 'text-center'
align: 'center',
headerAlign: 'center'
},
cell: (info: CellContext<LogbookEntry, unknown>) => {
return (
<Actions id={info.row.original.id} />
<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 columns: ColumnDef<LogbookEntry>[] = [
const unauthColumns: ColumnDef<LogbookEntry>[] = [
pilotName,
date,
aircraftMakeModel,
route,
durationOfFlight,
notes
]
const authColumns: ColumnDef<LogbookEntry>[] = [
pilotName,
date,
aircraftMakeModel,
@@ -158,8 +173,8 @@ const Logbook: React.FC<unknown> = () => {
accessorKey: 'singleEngineLand',
header: 'Single Engine Land',
meta: {
align: 'text-right',
headerAlign: 'text-right'
align: 'right',
headerAlign: 'right'
},
cell: (info: CellContext<LogbookEntry, unknown>) =>
info.getValue() ? parseFloat(info.getValue() as string).toFixed(1) : '',
@@ -169,8 +184,7 @@ const Logbook: React.FC<unknown> = () => {
id: 'landings',
header: 'Landings',
meta: {
className: 'border-l border-base-300',
headerAlign: 'text-center'
headerAlign: 'center'
},
columns: [
{
@@ -179,9 +193,8 @@ const Logbook: React.FC<unknown> = () => {
header: 'Day',
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info) : '',
meta: {
align: 'text-right',
className: 'border-l border-base-300',
headerAlign: 'text-right'
align: 'right',
headerAlign: 'right'
}
},
{
@@ -190,8 +203,8 @@ const Logbook: React.FC<unknown> = () => {
header: 'Night',
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info) : '',
meta: {
align: 'text-right',
headerAlign: 'text-right'
align: 'right',
headerAlign: 'right'
}
}
]
@@ -200,8 +213,7 @@ const Logbook: React.FC<unknown> = () => {
id: 'instrument',
header: 'Instrument',
meta: {
className: 'border-l border-base-300',
headerAlign: 'text-center'
headerAlign: 'center'
},
columns: [
{
@@ -210,9 +222,8 @@ const Logbook: React.FC<unknown> = () => {
header: 'Actual',
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
meta: {
align: 'text-right',
className: 'border-l border-base-300',
headerAlign: 'text-right'
align: 'right',
headerAlign: 'right'
},
cell: (info: CellContext<LogbookEntry, unknown>) =>
info.getValue() ? parseFloat(info.getValue() as string).toFixed(1) : '',
@@ -223,8 +234,8 @@ const Logbook: React.FC<unknown> = () => {
header: 'Simulated',
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
meta: {
align: 'text-right',
headerAlign: 'text-right'
align: 'right',
headerAlign: 'right'
},
cell: (info: CellContext<LogbookEntry, unknown>) =>
info.getValue() ? parseFloat(info.getValue() as string).toFixed(1) : ''
@@ -235,8 +246,8 @@ const Logbook: React.FC<unknown> = () => {
header: 'Approaches',
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
meta: {
align: 'text-right',
headerAlign: 'text-right'
align: 'right',
headerAlign: 'right'
}
},
{
@@ -245,8 +256,8 @@ const Logbook: React.FC<unknown> = () => {
header: 'Holds',
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
meta: {
align: 'text-right',
headerAlign: 'text-right'
align: 'right',
headerAlign: 'right'
}
},
{
@@ -255,8 +266,8 @@ const Logbook: React.FC<unknown> = () => {
header: 'Nav/Track',
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
meta: {
align: 'text-right',
headerAlign: 'text-right'
align: 'right',
headerAlign: 'right'
}
}
]
@@ -265,8 +276,7 @@ const Logbook: React.FC<unknown> = () => {
id: 'experienceTraining',
header: 'Type of pilot experience or training',
meta: {
className: 'border-l border-base-300',
headerAlign: 'text-center'
headerAlign: 'center'
},
columns: [
{
@@ -275,9 +285,8 @@ const Logbook: React.FC<unknown> = () => {
header: 'Ground Training Received',
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
meta: {
align: 'text-right',
className: 'border-l border-base-300',
headerAlign: 'text-right'
align: 'right',
headerAlign: 'right'
},
cell: (info: CellContext<LogbookEntry, unknown>) =>
info.getValue() ? parseFloat(info.getValue() as string).toFixed(1) : ''
@@ -288,8 +297,8 @@ const Logbook: React.FC<unknown> = () => {
header: 'Flight Training Received',
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
meta: {
align: 'text-right',
headerAlign: 'text-right'
align: 'right',
headerAlign: 'right'
},
cell: (info: CellContext<LogbookEntry, unknown>) =>
info.getValue() ? parseFloat(info.getValue() as string).toFixed(1) : ''
@@ -300,8 +309,8 @@ const Logbook: React.FC<unknown> = () => {
header: 'Cross Country',
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
meta: {
align: 'text-right',
headerAlign: 'text-right'
align: 'right',
headerAlign: 'right'
},
cell: (info: CellContext<LogbookEntry, unknown>) =>
info.getValue() ? parseFloat(info.getValue() as string).toFixed(1) : ''
@@ -312,8 +321,8 @@ const Logbook: React.FC<unknown> = () => {
header: 'Night',
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
meta: {
align: 'text-right',
headerAlign: 'text-right'
align: 'right',
headerAlign: 'right'
},
cell: (info: CellContext<LogbookEntry, unknown>) =>
info.getValue() ? parseFloat(info.getValue() as string).toFixed(1) : ''
@@ -324,8 +333,8 @@ const Logbook: React.FC<unknown> = () => {
header: 'Solo',
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
meta: {
align: 'text-right',
headerAlign: 'text-right'
align: 'right',
headerAlign: 'right'
},
cell: (info: CellContext<LogbookEntry, unknown>) =>
info.getValue() ? parseFloat(info.getValue() as string).toFixed(1) : ''
@@ -336,8 +345,8 @@ const Logbook: React.FC<unknown> = () => {
header: 'Pilot In Command',
footer: (info: HeaderContext<LogbookEntry, unknown>) => columnTotal(info) > 0 ? columnTotal(info).toFixed(1) : '',
meta: {
align: 'text-right',
headerAlign: 'text-right'
align: 'right',
headerAlign: 'right'
},
cell: (info: CellContext<LogbookEntry, unknown>) =>
info.getValue() ? parseFloat(info.getValue() as string).toFixed(1) : ''
@@ -346,87 +355,31 @@ const Logbook: React.FC<unknown> = () => {
},
notes,
actions
];
]
const onPaginationChange = (updater: any) => {
const newPaginationState: PaginationState = typeof updater === 'function' ? updater(state.pagination) : updater;
dispatch({ type: 'SET_PAGINATION', payload: newPaginationState })
}
const table = useReactTable({
data: state.entries,
columns: columns,
getCoreRowModel: getCoreRowModel(),
manualPagination: true,
onColumnVisibilityChange: setColumnVisibility,
onPaginationChange: onPaginationChange,
rowCount: state.totalEntries,
state: {
columnVisibility: columnVisibility,
pagination: state.pagination
}
});
const {
firstPage,
getCanNextPage,
getCanPreviousPage,
getPageCount,
getState,
lastPage,
nextPage,
previousPage,
setPageIndex
} = table;
const getLogbookEntries = async (pageIndex: number, pageSize: number) => {
const getLogbookEntries = async () => {
try {
dispatch({ type: 'SET_IS_LOADING', payload: true });
const response: AxiosResponse = await httpClient.get(`api/logs`, {
params: {
skip: pageIndex * pageSize,
take: pageSize
}
});
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.entities.length > 0) {
const entries: LogbookEntry[] = response.data.entities;
const entryColumns: string[] = Object.keys(entries[0]);
const columnVisibility: {[key: string]: boolean} = {}
if (response.data.length > 0) {
dispatch({ type: 'SET_ENTRIES', payload: response.data });
for (const column of table.getAllLeafColumns()) {
const entryColumnExists = entryColumns.find((entryColumn) => entryColumn === column.id);
if (entryColumnExists) {
columnVisibility[column.id] = true
} else {
columnVisibility[column.id] = false
}
}
columnVisibility['pilotName'] = true
columnVisibility['actions'] = true
entries.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime());
setColumnVisibility(columnVisibility)
dispatch({ type: 'SET_ENTRIES', payload: { entries: entries, totalEntries: response.data.total }});
if (!isUserLoggedIn && response.data.entities.length >= 5) {
dispatch({ type: 'SET_ALERT', payload: { severity: 'info', message: 'A limited number of log entries displayed. Sign in to view all log entries.'}})
} else {
if (state.alert) {
dispatch({ type: 'SET_ALERT', payload: undefined})
}
} else {
dispatch({ type: 'SET_ALERT', payload: { severity: 'info', message: 'No logbook entries found.'}})
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: 'error', message: `Loading of logbook entries failed with the following message: ${axiosError.message}`}
payload: { severity: 'danger', message: `Loading of logbook entries failed with the following message: ${axiosError.message}`}
});
} finally {
dispatch({ type: 'SET_IS_LOADING', payload: false });
@@ -478,13 +431,13 @@ const Logbook: React.FC<unknown> = () => {
payload: false
});
logbookContext.dispatch({ type: 'SET_SELECTED_LOG_ID', payload: '' })
await getLogbookEntries(state.pagination?.pageIndex, state.pagination?.pageSize);
await getLogbookEntries();
} catch (error) {
const axiosError = error as AxiosError;
dispatch({
type: 'SET_ALERT',
payload: { severity: 'error', message: `Loading of logbook entries failed with the following message: ${axiosError.message}`}
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 });
@@ -498,269 +451,181 @@ const Logbook: React.FC<unknown> = () => {
});
};
const onRowsPerPageChanged = (event: any) => {
const newPaginationState: PaginationState = {
pageIndex: 0,
pageSize: event.target.value !== 'All' ? Number(event.target.value) : state.totalEntries
};
// useEffect(() => {
// let newColumns: ColumnDef<LogbookEntry>[];
dispatch({ type: 'SET_PAGINATION', payload: newPaginationState })
}
// 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(state.pagination.pageIndex, state.pagination.pageSize);
getLogbookEntries();
}
}, [logbookContext.state.isDrawerOpen, state.pagination.pageIndex, state.pagination.pageSize]);
useEffect(() => {
const pages: number[] = [];
if (state.pagination?.pageSize) {
const totalPages = getPageCount();
for(let i = 0; i < totalPages; i++) {
pages.push(i + 1)
}
}
dispatch({ type: 'SET_PAGES', payload: pages })
}, [state.totalEntries, state.pagination?.pageSize])
useEffect(() => {
console.log(state.alert)
}, [state.alert])
}, [logbookContext.state.isDrawerOpen]);
return (
<>
<div className={`${screenSize === ScreenSize.SM ? 'mr-4 ml-4' : 'mr-10 ml-10'} grid grid-cols-12`}>
<div className='prose max-w-none col-span-6 mt-5 mb-5'>
<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-6 justify-self-end self-center'>
<div className='col-span-2 justify-self-end self-center'>
{userRole === UserRole.WRITE &&
<button className='btn btn-primary'
onClick={() => onOpenCloseDrawer(FormMode.ADD)}
<Button
color='primary'
onPress={() => onOpenCloseDrawer(FormMode.ADD)}
startContent={<FontAwesomeIcon icon={faAdd} />}
data-testid="pilot-add-button"
data-theme="lofi"
>
<FontAwesomeIcon icon={faAdd} />
Add Entry
</button>
</Button>
}
</div>
{!state.isLoading && state.alert && (
<div className='col-span-12 mb-5'>
<Alert
className='mb-5'
onClose={() =>
dispatch({ type: 'SET_ALERT', payload: undefined })
}
severity={state.alert.severity}
>
{state.alert.message}
</Alert>
color={'default'}
title={state.alert.message}
/>
</div>
)}
{!state.isLoading && state.entries.length > 0 && screenSize !== ScreenSize.SM && screenSize !== ScreenSize.MD && (
<div className='col-span-12 pr-5 pb-5 pl-5 bg-base-100 border border-base-100 rounded-lg'>
<div className='overflow-x-auto mb-5'>
<div className='col-span-12 justify-self-end self-center mt-1 mr-1 mb-2'>
<label className='select select-sm select-ghost'>
<span className='label'>Rows per page</span>
<select
defaultValue={1}
onChange={onRowsPerPageChanged}
value={state.pagination?.pageSize}
>
<option value={10}>10</option>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={state.totalEntries}>All</option>
</select>
</label>
</div>
<table className='table min-w-full h-auto table-auto w-full'>
<thead className='bg-base-200'>
{table.getHeaderGroups().map((headerGroup, headerGroupIndex) => (
<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, headerIndex) => {
{!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 (
<th
className={`${header.column.columnDef.meta?.className ? header.column.columnDef.meta?.className : ''} group/th px-3 h-10 align-middle whitespace-nowrap text-foreground-500 text-tiny font-semibold ${headerGroupIndex === 0 ? 'first:rounded-tl-lg last:rounded-tr-lg' : ''} ${headerGroupIndex === table.getHeaderGroups().length - 1 ? 'first:rounded-bl-lg last:rounded-br-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 className={`${header.column.columnDef.meta?.headerAlign ? header.column.columnDef.meta?.headerAlign : ''}`}>
{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={`py-2 px-3 relative align-middle whitespace-normal text-small font-normal [&>*]: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}
>
<div className={`${cell.column.columnDef.meta?.align ? cell.column.columnDef.meta?.align : ''}`}>
<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()
)}
</div>
</td>
);
})}
</tr>
);
})}
</>
</tbody>
<thead className='[&>tr]:first:rounded-lg bg-base-200'>
{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='roup/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}
>
<div className={`${header.column.columnDef.meta?.headerAlign ? header.column.columnDef.meta?.headerAlign : ''}`}>
</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()
)
}
</div>
</td>
);
})}
</tr>
);
}
})}
</thead>
</table>
</div>
{state.pagination.pageSize !== state.totalEntries &&
<div className='col-span-12 justify-self-center self-center'>
<div className='join'>
<button
className='join-item btn btn-sm'
onClick={firstPage}
>
<FontAwesomeIcon icon={faAnglesLeft} />
</button>
<button
className='join-item btn btn-sm'
onClick={previousPage}
disabled={!getCanPreviousPage()}
>
<FontAwesomeIcon icon={faAngleLeft} />
</button>
{state.pages.map((page, index) => {
return (
<button
className={`join-item btn btn-sm ${getState().pagination.pageIndex === index ? 'btn-active' : ''}`}
onClick={() => setPageIndex(index)}
>
{page}
</button>
)
})}
<button
className='join-item btn btn-sm'
onClick={() => nextPage()}
disabled={!getCanNextPage()}
>
<FontAwesomeIcon icon={faAngleRight} />
</button>
<button
className='join-item btn btn-sm'
onClick={lastPage}
>
<FontAwesomeIcon icon={faAnglesRight} />
</button>
</div>
)}
</td>
);
})}
</tr>
);
}
})}
</thead>
</table>
</div>
)}
{state.entries.length > 0 && screenSize === ScreenSize.SM &&
<LogbookCard logs={state.entries} onDelete={onDeleteLog} mode='logbook' onOpenCloseForm={onOpenCloseDrawer} />
}
</div>
)}
{state.entries.length > 0 && screenSize === ScreenSize.SM &&
<div className='col-span-12'>
<>
{table.getRowModel().rows.map((row) => {
const date = new Date(row.original.date.replace('Z', ''));
const formattedDate: string = `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`;
return (
<div className='card bg-base-100 border border-base-300 mb-5'>
<div className={`card-body ${screenSize === ScreenSize.SM || screenSize === ScreenSize.MD ? 'p-4' : ''}`} key={row.id}>
<div className={`grid grid-cols-12 gap-3`}>
<>
<div className='col-span-8'>
<h2 className='card-title font-bold text-2xl'>{formattedDate}</h2>
</div>
<div className='col-span-4 justify-self-end self-center'>
<Actions id={row.original.id} />
</div>
{row.getVisibleCells().map((cell) => {
return (
<>
{cell.column.columnDef.header !== 'Actions' && cell.column.columnDef.header !== 'Date' && cell.column.columnDef.header !== 'Pilot' &&
<>
<div className='col-span-8 font-bold'>
<span>{cell.getContext().column.columnDef.header?.toString()}</span>
</div>
<div className='col-span-4'>
<span>{flexRender(cell.column.columnDef.cell, cell.getContext())}</span>
</div>
</>
}
</>
)
})}
</>
</div>
</div>
</div>
)
})}
</>
</div>
}
{state.isLoading && !state.alert && (
<div className='col-span-12 p-5 bg-base-100 border border-base-100 rounded-lg'>
<div className='col-span-12 justify-self-center'>
<span className='loading loading-spinner loading-xl' />
{/* {state.isLoading && !state.alert && (
<>
<div>
<Loading size='xl' />
</div>
<div className='col-span-12 justify-self-center'>
<div>
Loading...
</div>
</div>
)}
</>
)} */}
</div>
{logbookContext.state.isDrawerOpen && (
<LogbookDrawer
@@ -777,6 +642,14 @@ const Logbook: React.FC<unknown> = () => {
title="Confirm Delete"
/>
)}
{/* {state.isTracksOpen &&
<LogTracks
isDrawerOpen={state.isTracksOpen}
mode={state.tracksMode}
onOpenClose={(mode) => onOpenCloseTracks(mode)}
selectedLogId={logbookContext.state.selectedLogId}
/>
} */}
</>
);
};

View File

@@ -1,4 +1,4 @@
import { ColumnDef, PaginationState } from '@tanstack/react-table';
import { ColumnDef } from '@noahspan/noahspan-components';
import { FormMode } from '../../enums/formMode';
import { Alert } from '../../interfaces/Alert.interface';
import { LogbookEntry } from './LogbookEntry.interface';
@@ -10,7 +10,4 @@ export interface LogbookState {
isConfirmDialogLoading: boolean;
isConfirmDialogOpen: boolean;
isLoading: boolean;
pages: number[];
pagination: PaginationState;
totalEntries: number;
}

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

@@ -1,4 +1,4 @@
import { ColumnDef, PaginationState } from '@tanstack/react-table';
import { ColumnDef } from '@noahspan/noahspan-components';
import { FormMode } from '../../enums/formMode';
import { Alert } from '../../interfaces/Alert.interface';
import { LogbookEntry } from './LogbookEntry.interface';
@@ -7,12 +7,11 @@ 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: { entries: LogbookEntry[], totalEntries: number } }
| { 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 }
| { type: 'SET_PAGES'; payload: number[] }
| { type: 'SET_PAGINATION'; payload: PaginationState };
| { type: 'SET_IS_LOADING'; payload: boolean };
export const initialState: LogbookState = {
alert: undefined,
@@ -20,13 +19,7 @@ export const initialState: LogbookState = {
entries: [],
isConfirmDialogLoading: false,
isConfirmDialogOpen: false,
isLoading: false,
pages: [],
pagination: {
pageIndex: 0,
pageSize: 10
},
totalEntries: 0
isLoading: false
};
export const reducer = (
@@ -49,8 +42,7 @@ export const reducer = (
case 'SET_ENTRIES': {
return {
...state,
entries: action.payload.entries,
totalEntries: action.payload.totalEntries
entries: action.payload
};
}
case 'SET_ALERT': {
@@ -71,18 +63,6 @@ export const reducer = (
isLoading: action.payload
};
}
case 'SET_PAGES': {
return {
...state,
pages: action.payload
}
}
case 'SET_PAGINATION': {
return {
...state,
pagination: action.payload
}
}
default: {
return state;
}

View File

@@ -1,23 +1,22 @@
import { Accordion, AccordionItem, Card, CardBody, CardHeader } from '@heroui/react'
import { LogbookCardProps } from "./LogbookCardProps.interface";
import TrackMap from "../trackMap/TrackMap";
import { useBreakpoints } from "../../hooks/useBreakpoints/UseBreakpoints";
import { ScreenSize } from "../../enums/screenSize";
import { useEffect } from "react";
const LogbookCard = ({ logs, mode, onDelete, onOpenCloseForm }: LogbookCardProps) => {
const { screenSize } = useBreakpoints();
return (
<div>
{logs.map((log) => {
const date = new Date(log.date.replace('Z', ''));
const formattedDate: string = `${date.getMonth() + 1}/${date.getDate()}/${date.getFullYear()}`;
console.log(log)
const date = new Date(log.date);
const formattedDate: string = `${date.getMonth()}/${date.getDate()}/${date.getFullYear()}`;
return (
<div className='card bg-base-100 border border-base-300 p-2 mb-5'>
<div className={`card-body ${screenSize === ScreenSize.SM || screenSize === ScreenSize.MD ? 'p-2' : ''}`} key={log.id}>
<h2 className='card-title font-bold text-2xl'>{formattedDate}</h2>
<div>
<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
@@ -27,50 +26,49 @@ const LogbookCard = ({ logs, mode, onDelete, onOpenCloseForm }: LogbookCardProps
/>
</div>
}
</div>
<div className='collapse collapse-arrow bg-base-100 border-base-300 border'>
<input type='checkbox' />
<div className='collapse-title font-semibold'>Details</div>
<div className='collapse-content'>
<div className={`grid grid-cols-12 gap-3 ${screenSize === ScreenSize.SM || screenSize === ScreenSize.MD ? 'mr-[5%] ml-[5%]' : 'mr-[30%] ml-[30%]'} mt-4 mb-4`}>
<div className='col-span-6 font-bold'>
<span>Aircraft Make and Model</span>
<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>
<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>
</div>
</div>
</div>
</AccordionItem>
</Accordion>
</CardBody>
</Card>
</div>
)
})}

View File

@@ -1,8 +1,7 @@
import { useState } from "react";
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 Alert from '../alert/Alert';
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faClock, faMapLocationDot, faSave, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FormProvider, useForm } from "react-hook-form";
@@ -10,11 +9,10 @@ import { FormMode } from "../../enums/formMode";
import httpClient from "../../httpClient/httpClient";
import { AxiosError } from "axios";
import { useLogbookContext } from "../../hooks/logbookContext/UseLogbookContext";
import { useBreakpoints } from "../../hooks/useBreakpoints/UseBreakpoints";
import { ScreenSize } from "../../enums/screenSize";
import { Key, useState } from "react";
const LogbookDrawer = ({ onOpenClose }: LogbookDrawerProps) => {
const [activeTab, setActiveTab] = useState<string>('time');
const [activeTab, setActiveTab] = useState<Key>('time');
const defaultValues = {
pilotId: '',
date: null,
@@ -42,7 +40,6 @@ const LogbookDrawer = ({ onOpenClose }: LogbookDrawerProps) => {
};
const methods = useForm();
const logbookContext = useLogbookContext()
const { screenSize } = useBreakpoints();
const onCancel = () => {
methods.reset(defaultValues);
@@ -51,8 +48,8 @@ const LogbookDrawer = ({ onOpenClose }: LogbookDrawerProps) => {
};
const onSubmit = async (data: unknown) => {
console.log(data)
try {
logbookContext.dispatch({ type: 'SET_IS_FORM_DISABLED', payload: true });
logbookContext.dispatch({ type: 'SET_IS_FORM_LOADING', payload: true });
if (!logbookContext.state.selectedLogId) {
@@ -62,116 +59,116 @@ const LogbookDrawer = ({ onOpenClose }: LogbookDrawerProps) => {
}
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: 'error', message: axiosError.message }});
logbookContext.dispatch({ type: 'SET_FORM_ALERT', payload: { severity: 'danger', message: axiosError.message }});
} finally {
logbookContext.dispatch({ type: 'SET_IS_FORM_LOADING', payload: false });
logbookContext.dispatch({ type: 'SET_IS_FORM_DISABLED', payload: false });
logbookContext.dispatch({ type: 'SET_FORM_MODE', payload: FormMode.CANCEL });
onOpenClose(FormMode.CANCEL);
}
};
const onTabClicked = (event: any) => {
setActiveTab(event.target.ariaLabel)
const onSelectedKeyChanged = (key: React.Key) => {
setActiveTab(key)
}
return (
<div className='drawer drawer-end'>
<input type='checkbox' className='drawer-toggle' onChange={() => {}} checked={logbookContext.state.isDrawerOpen} />
<div className="drawer-side">
<label
htmlFor='my-drawer-1'
aria-label='close-sidebar'
className='drawer-overlay'
></label>
<div className={`menu bg-base-100 text-base-content min-h-full p-4 ${screenSize === ScreenSize.SM ? 'w-full' : screenSize === ScreenSize.MD ? 'w-[66%]' : 'w-[25%]'}`}>
<FormProvider {...methods}>
<form className='prose max-w-none' onSubmit={methods.handleSubmit(onSubmit)} style={{ paddingBottom: '50px' }}>
<div className='grid grid-cols-12 mb-6'>
<div className="col-span-10">
<h2 className="mt-0 mb-0 self-center">
{`${logbookContext.state.formMode.toString().toLowerCase().charAt(0).toUpperCase() + logbookContext.state.formMode.toString().slice(1).toLowerCase()} Entry`}
</h2>
</div>
<div className="col-span-2 justify-self-end self-center">
<button className="btn btn-ghost" onClick={onCancel}>
<FontAwesomeIcon icon={faXmark} />
</button>
</div>
</div>
{logbookContext.state.formAlert && (
<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
className='mb-5'
onClose={() =>
logbookContext.dispatch({ type: 'SET_FORM_ALERT', payload: undefined })
}
severity={logbookContext.state.formAlert.severity}
>
{logbookContext.state.formAlert.message}
</Alert>
)}
<div className='tabs tabs-lift mb-5'>
<label className='tab'>
<input aria-label='time' type='radio' name='logbook_drawer_tabs' onChange={onTabClicked} checked={activeTab === 'time' ? true : false} />
<FontAwesomeIcon className='mr-1'icon={faClock} />
Time
</label>
<div className='tab-content border-base-300 p-6'>
<LogForm />
</div>
{logbookContext.state.formMode !== FormMode.ADD &&
<>
<label className='tab'>
<input aria-label='tracks' type='radio' name='logbook_drawer_tabs' onChange={onTabClicked} checked={activeTab === 'tracks' ? true : false} />
<FontAwesomeIcon className='mr-1' icon={faMapLocationDot} />
Tracks
</label>
<div className='tab-content border-base-300 p-6'>
<TracksForm />
</div>
</>
}
color={logbookContext.state.formAlert.severity}
title={logbookContext.state.formAlert.message}
/>
</div>
{activeTab !== 'tracks' &&
<div>
<div className='grid grid-cols-12 gap-3'>
<div className='col-span-12 justify-self-end self-center'>
<button
className='btn'
disabled={
logbookContext.state.isFormDisabled && logbookContext.state.formMode.toString() !== FormMode.VIEW
? logbookContext.state.isFormDisabled
: false
}
onClick={onCancel}
>
<FontAwesomeIcon icon={faXmark} />
{logbookContext.state.formMode.toString() !== FormMode.VIEW ? 'Cancel' : 'Close'}
</button>
{logbookContext.state.formMode.toString() !== FormMode.VIEW && (
<button
className='btn btn-primary ml-2.5'
disabled={logbookContext.state.isFormDisabled}
type="submit"
>
<FontAwesomeIcon icon={faSave} />
Save
</button>
)}
</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>
</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>
}
</form>
</FormProvider>
</div>
</div>
</div>
</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>
)
}

View File

@@ -1,26 +1,26 @@
// import { Card, CardActions, CardBody, CardHeader} from "@noahspan/noahspan-components"
// import { PilotCardProps } from "./PilotCardProps.interface"
// import ActionMenu from "../actionMenu/ActionMenu"
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>
// )
// }
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;
export default PilotCard;

View File

@@ -1,19 +1,18 @@
import { useEffect, useState } from 'react';
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 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';
import { useBreakpoints } from '../../hooks/useBreakpoints/UseBreakpoints';
import { ScreenSize } from '../../enums/screenSize';
const PilotForm: React.FC<IPilotFormProps> = ({
pilotId,
@@ -45,7 +44,39 @@ const PilotForm: React.FC<IPilotFormProps> = ({
});
const [isDisabled, setIsDisabled] = useState<boolean>(false);
const [isError, setIsError] = useState<boolean>(false);
const { screenSize } = useBreakpoints();
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);
@@ -107,225 +138,245 @@ const PilotForm: React.FC<IPilotFormProps> = ({
}, [pilotId]);
return (
<div className='drawer drawer-end'>
<input type='checkbox' className='drawer-toggle' onChange={() => {}} checked={isDrawerOpen} />
<div className='drawer-side'>
<label
htmlFor='my-drawer-1'
aria-label='close-sidebar'
className='drawer-overlay'
></label>
<div className={`menu bg-base-100 text-base-content min-h-full p-4 ${screenSize === ScreenSize.SM ? 'w-full' : screenSize === ScreenSize.MD ? 'w-[66%]' : 'w-[25%]'}`}>
<FormProvider {...methods}>
<form className='prose max-w-none' onSubmit={methods.handleSubmit(onSubmit)} style={{ paddingBottom: '50px' }}>
<div className='grid grid-cols-12 gap-3'>
<div className="col-span-10">
<h2 className="mt-0 mb-0 self-center">
{`${mode.toString().toLowerCase().charAt(0).toUpperCase() + mode.toString().slice(1).toLowerCase()} Pilot`}
</h2>
</div>
<div className="col-span-2 justify-self-end self-center">
<button className="btn btn-ghost" onClick={onCancel}>
<FontAwesomeIcon icon={faXmark} />
</button>
</div>
<div className='col-span-3 self-center'>
<span>Name *</span>
</div>
<div className='col-span-9'>
<Controller
name="name"
control={methods.control}
rules={{ required: 'A name is required' }}
render={({ field: { onChange, value } }) => (
<input
type='text'
className='input w-full'
disabled={isDisabled}
onChange={onChange}
value={value}
/>
)}
/>
</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
type='text'
className='input w-full'
disabled={isDisabled}
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
type='text'
className='input w-full'
disabled={isDisabled}
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
className='select w-full'
onChange={onChange}
value={[value]}
>
{states.map((state) => (
<option key={state.value}>{state.label}</option>
))}
</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
type='text'
className='input w-full'
disabled={isDisabled}
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
type='text'
className='input w-full'
disabled={isDisabled}
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
type='text'
className='input w-full'
disabled={isDisabled}
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 className='col-span-12 justify-self-end self-center'>
<button
className='btn'
disabled={
isDisabled && mode.toString() !== FormMode.VIEW
? isDisabled
: false
}
onClick={onCancel}
data-testid="pilot-cancel-button"
>
<FontAwesomeIcon icon={faXmark} />
{mode.toString() !== FormMode.VIEW ? 'Cancel' : 'Close'}
</button>
{mode.toString() !== FormMode.VIEW && (
<button
className='btn btn-primary ml-2.5'
disabled={isDisabled}
type="submit"
data-testid="pilot-save-button"
>
<FontAwesomeIcon icon={faSave} />
Save
</button>
)}
</div>
<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>
</form>
</FormProvider>
</div>
</div>
</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>
);
};

View File

@@ -1,153 +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';
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 PilotFormCertificates = ({
isDisabled,
mode
}: PilotFormCertificatesProps ) => {
const {
control,
formState: { errors },
} = useFormContext();
// const { fields, append, remove } = useFieldArray({
// name: 'certificates',
// control
// });
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>
// );
// };
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;
export default PilotFormCertificates;

View File

@@ -1,129 +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';
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 PilotFormEndorsements = ({
mode,
isDisabled
}: PilotFormEndorsementsProps) => {
const {
control,
formState: { errors },
setValue
} = useFormContext();
// const { fields, append, remove } = useFieldArray({
// name: 'endorsements',
// control
// });
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>
// );
// };
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;
export default PilotFormEndorsements;

View File

@@ -1,77 +1,77 @@
// import { DatePicker, Select } from '@noahspan/noahspan-components';
// import { Controller, useFormContext } from "react-hook-form"
// import { PilotFormMedicalProps } from "./PilotFormMedicalProps.interface";
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();
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>
// )
// }
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
export default PilotFormMedical

View File

@@ -4,44 +4,20 @@ 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 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';
import { CellContext, ColumnDef, flexRender, getCoreRowModel, getPaginationRowModel, useReactTable } from '@tanstack/react-table';
import { Pilot } from './Pilot.interface';
import Alert from '../alert/Alert';
import { useOidc } from '../../auth/oidcConfig';
interface ActionsProps {
id: string;
}
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 { isUserLoggedIn } = useOidc();
const Actions = ({ id }: ActionsProps) => {
return (
<div className='dropdown dropdown-end'>
<div tabIndex={0} role='button' className='btn btn-ghost'><FontAwesomeIcon icon={faEllipsisVertical} /></div>
<ul tabIndex={-1} className="dropdown-content menu bg-base-100 rounded-box z-1 w-52 p-2 shadow-sm border border-base-300">
{isUserLoggedIn && userRole === UserRole.WRITE &&
<li><a onClick={() => onOpenClosePilotForm(FormMode.EDIT, id)}><FontAwesomeIcon icon={faPen} />Edit</a></li>
}
<li><a onClick={() => onOpenClosePilotForm(FormMode.VIEW, id)}><FontAwesomeIcon icon={faEye} />View</a></li>
{isUserLoggedIn && userRole === UserRole.WRITE &&
<li><a onClick={() => onDeletePilot(id)}><FontAwesomeIcon icon={faTrash} />Delete</a></li>
}
</ul>
</div>
)
}
const { screenSize } = useBreakpoints()
const getPilots = async () => {
try {
@@ -58,7 +34,7 @@ const Pilots: React.FC<unknown> = () => {
dispatch({ type: 'SET_ALERT', payload: undefined })
}
} else {
dispatch({ type: 'SET_ALERT', payload: { severity: 'info', message: 'No pilots found.' }})
dispatch({ type: 'SET_ALERT', payload: { severity: 'default', message: 'No pilots found.' }})
dispatch({ type: 'SET_PILOTS', payload: [] });
}
} catch (error) {
@@ -66,7 +42,7 @@ const Pilots: React.FC<unknown> = () => {
dispatch({
type: 'SET_ALERT',
payload: { severity: 'error', message: `Loading of pilots failed with the following message: ${axiosError.message}`}
payload: { severity: 'danger', message: `Loading of pilots failed with the following message: ${axiosError.message}`}
})
} finally {
dispatch({ type: 'SET_IS_LOADING', payload: false })
@@ -74,6 +50,7 @@ const Pilots: React.FC<unknown> = () => {
};
const onOpenClosePilotForm = async (mode: FormMode, pilotId?: string) => {
console.log(pilotId)
switch (mode) {
case FormMode.ADD:
case FormMode.EDIT:
@@ -125,7 +102,7 @@ const Pilots: React.FC<unknown> = () => {
dispatch({
type: 'SET_ALERT',
payload: { severity: 'error', message: `Loading of logbook entries failed with the following message: ${axiosError.message}`}
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 });
@@ -139,39 +116,64 @@ const Pilots: React.FC<unknown> = () => {
});
};
const columns: ColumnDef<Pilot>[]= [
const columns = [
{
id: 'name',
accessorKey: 'name',
header: 'Name'
name: 'Name'
},
{
id: 'actions',
header: 'Actions',
meta: {
align: 'text-center',
headerAlign: 'text-center'
},
cell: (info: CellContext<Pilot, unknown>) => {
return (
<Actions id={info.row.original.id} />
)
}
name: 'Actions'
}
]
const textAlignment = {
center: 'text-center',
left: 'text-start',
right: 'text-end'
};
const renderCell = (pilot: any, columnKey: any) => {
const cellValue = pilot[columnKey]
const table = useReactTable({
data: state.pilots,
columns: columns,
getCoreRowModel: getCoreRowModel(),
getPaginationRowModel: getPaginationRowModel()
});
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) {
@@ -181,127 +183,63 @@ const Pilots: React.FC<unknown> = () => {
return (
<>
<div className={`${screenSize === ScreenSize.SM ? 'mr-4 ml-4' : 'mr-10 ml-10'} grid grid-cols-12`}>
<div className='prose max-w-none col-span-6 mt-5 mb-5' >
<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-6 justify-self-end self-center'>
{!state.isLoading && userRole === UserRole.WRITE &&
<button
className='btn btn-primary'
onClick={() => onOpenClosePilotForm(FormMode.ADD)}
<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"
>
<FontAwesomeIcon icon={faPlus} />
Add Pilot
</button>
</Button>
}
</div>
{!state.isLoading && state.alert && (
<div className='col-span-12'>
<Alert
className='mb-5'
onClose={() =>
dispatch({ type: 'SET_ALERT', payload: undefined })
}
severity={state.alert.severity}
>
{state.alert.message}
</Alert>
color={state.alert.severity}
title={state.alert.message}
/>
</div>
)}
{state.pilots.length > 0 && screenSize !== ScreenSize.SM &&
<div className='col-span-12 bg-base-100 p-5 border border-base-100 rounded-lg'>
<table className='table min-w-full h-auto table-auto w-full'>
<thead className='[&>tr]:first:rounded-lg bg-base-200'>
{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?.headerAlign ? header.column.columnDef.meta?.headerAlign : ''} group/th px-3 h-10 align-middle bg-default-100 whitespace-nowrap text-foreground-500 text-tiny font-semibold rounded 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 ? 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>
</table>
</div>
}
{state.pilots.length > 0 && screenSize === ScreenSize.SM &&
<div className='col-span-12'>
<>
{table.getRowModel().rows.map((row) => {
return (
<div className='card bg-base-100 border border-base-300 mb-5'>
<div className={`card-body ${screenSize === ScreenSize.SM || screenSize === ScreenSize.MD ? 'p-4' : ''}`} key={row.id}>
<div className={`grid grid-cols-12 gap-3`}>
<>
{row.getVisibleCells().map((cell) => {
return (
<>
{cell.column.columnDef.header !== 'Actions' &&
<>
<div className='col-span-10 self-center'>
<span>{flexRender(cell.column.columnDef.cell, cell.getContext())}</span>
</div>
<div className='col-span-2'>
<Actions id={row.original.id} />
</div>
</>
}
</>
)
})}
</>
</div>
</div>
</div>
)
})}
</>
</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

View File

@@ -3,18 +3,17 @@ 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 { faBars, faPlane, faSignIn, faSignOut } from '@fortawesome/free-solid-svg-icons'
import { NavLink, useLocation } from 'react-router-dom';
import { useBreakpoints } from '../../hooks/useBreakpoints/UseBreakpoints';
import { ScreenSize } from '../../enums/screenSize';
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 { screenSize } = useBreakpoints();
const { isUserLoggedIn, logout, login } = useOidc()
const { pathname } = useLocation()
const pages = [
{
name: 'Flights',
@@ -54,39 +53,13 @@ const SiteNav = () => {
}
};
const Brand = () => {
return (
<>
<img
className='mr-1'
height={35}
width={35}
src='noahspan-logo.png'
/>
<FontAwesomeIcon className='mt-1' icon={faPlane} size='2x' />
</>
)
}
const Links = () => {
return (
<>
{pages.map((page) => {
return (
<li><NavLink to={page.path}>{page.name}</NavLink></li>
)
})}
</>
)
}
useEffect(() => {
const setUserProfile = async () => {
try {
const userProfile = await getUserProfile();
// const userPhoto = await getUserPhoto();
const userPhoto = await getUserPhoto();
// setUserPhoto(userPhoto);
setUserPhoto(userPhoto);
appContext.dispatch({
type: 'SET_USER_PROFILE',
@@ -106,62 +79,53 @@ const SiteNav = () => {
}, [isUserLoggedIn]);
return (
<div className="navbar bg-base-100 shadow-sm w-full">
<div className={`navbar-start ${screenSize !== ScreenSize.SM && screenSize !== ScreenSize.MD ? 'ml-8' : ''}`}>
{screenSize === ScreenSize.SM || screenSize === ScreenSize.MD ? (
<div className="dropdown">
<div tabIndex={0} role="button" className="btn btn-ghost lg:hidden">
<FontAwesomeIcon icon={faBars} size='xl' />
</div>
<ul
tabIndex={-1}
className="menu menu-sm dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow">
<Links />
</ul>
</div>
) : (
<Brand />
)}
</div>
<div className="navbar-center">
<ul className="menu menu-horizontal px-1">
{screenSize === ScreenSize.SM || screenSize === ScreenSize.MD ? (
<Brand />
) : (
<Links />
)}
</ul>
</div>
<div className={`navbar-end ${screenSize !== ScreenSize.SM && screenSize !== ScreenSize.MD ? 'mr-8' : ''}`}>
<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 className='btn btn-ghost' onClick={() => login()}><FontAwesomeIcon icon={faSignIn} />Sign In</button>
<Button
color='default'
onPress={() => login()}
startContent={<FontAwesomeIcon icon={faSignIn} />}
>
Sign In
</Button>
}
{isUserLoggedIn &&
<div className='dropdown dropdown-end'>
<div tabIndex={0} role='button'>
<div className={`avatar ${userPhoto ? userPhoto : 'avatar-placeholder'}`}>
{userPhoto &&
<div className='w-12 rounded-full'>
<img src={userPhoto} />
</div>
}
{!userPhoto &&
<div className='bg-neutral text-neutral-content w-10 rounded-full'>
<span>NS</span>
</div>
}
</div>
</div>
<ul
tabIndex={0}
className='dropdown-content menu bg-base-100 rounded-box z-1 w-52 p-2 shadow-sm border border-base-300'
>
<li><a onClick={() => logout({redirectTo: 'specific url', url: '/'})}><FontAwesomeIcon icon={faSignOut} />Sign Out</a></li>
</ul>
</div>
<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>
}
</div>
</div>
</NavbarContent>
</Navbar>
);
};

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

@@ -4,6 +4,10 @@ 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'
@@ -37,7 +41,7 @@ const TrackMap = ({ height, logId, tracks }: TrackMapProps) => {
center={[45.14489, -93.21019]}
scrollWheelZoom={false}
style={{ height: height, width: '100%' }}
zoom={7}
zoom={8}
>
<Suspense>
<TileLayer

View File

@@ -7,6 +7,7 @@ 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";
@@ -27,7 +28,7 @@ const TracksForm = () => {
} catch (error) {
const axiosError = error as AxiosError;
logbookContext.dispatch({ type: 'SET_FORM_ALERT', payload: { severity: 'error', message: axiosError.message }})
logbookContext.dispatch({ type: 'SET_FORM_ALERT', payload: { severity: 'danger', message: axiosError.message }})
}
}
@@ -100,13 +101,23 @@ const TracksForm = () => {
}, [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);
@@ -114,25 +125,26 @@ const TracksForm = () => {
return (
<>
<div className='col-span-10'>
<input className='input w-full' disabled={state.isDisabled} key={index} readOnly type='text' value={filename} />
<Input isDisabled={state.isDisabled} key={index} type='text' value={filename}/>
</div>
<div className='col-span-2'>
<button className='btn w-full' disabled={state.isDisabled} key={index} onClick={() => onDeleteTrack(track.id, filename, index)}><FontAwesomeIcon icon={faTrash} /></button>
<Button isDisabled={state.isDisabled} key={index} isIconOnly onPress={() => onDeleteTrack(track.id, filename, index)}><FontAwesomeIcon icon={faTrash} /></Button>
</div>
</>
)
})}
{logbookContext.state.formMode === FormMode.EDIT &&
<div className='col-span-12'>
<label
className='btn cursor-pointer w-full'
>
<FontAwesomeIcon icon={faUpload} />
Upload Track
<input className='hidden' id='track-upload' onChange={handleFileUpload} type='file' />
</label>
</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?"

View File

@@ -1,7 +1,7 @@
export enum ScreenSize {
SM = 'SM',
MD = 'MD',
LG = 'LG',
XL = 'XL',
XXL = 'XXL'
SM,
MD,
LG,
XL,
XXL
}

3
client/src/hero.ts Normal file
View File

@@ -0,0 +1,3 @@
// hero.ts
import { heroui } from "@heroui/react";
export default heroui();

View File

@@ -11,20 +11,20 @@ export const useBreakpoints = () => {
switch (true) {
case width < 640: {
size = ScreenSize.SM;
console.log('small')
break;
}
case width >= 640 && width < 1024: {
case width >= 640: {
size = ScreenSize.MD;
break;
}
case width >= 1024 && width < 1280: {
case width >= 1024: {
size = ScreenSize.LG
break;
}
case width >= 1280 && width < 1536: {
case width >= 1280: {
size = ScreenSize.XL;
break;
@@ -42,10 +42,14 @@ export const useBreakpoints = () => {
const onWindowResize = () => {
const width: number = window.innerWidth;
const newScreenSize: ScreenSize = getWindowSize(width);
console.log(newScreenSize)
setScreenSize(newScreenSize);
}
useEffect(() => {
console.log(windowWidth)
}, [])
useEffect(() => {
onWindowResize()

View File

@@ -8,8 +8,7 @@ export const useUserRole = () => {
useEffect(() => {
if (isUserLoggedIn && decodedIdToken) {
const rolesKeyName: string | undefined = Object.keys(decodedIdToken).find((key) => key.includes('roles'));
const idTokenRoles: string[] = decodedIdToken[rolesKeyName!] as string[];
const idTokenRoles: string[] = decodedIdToken!.roles as string[];
let newUserRole: string | undefined;

View File

@@ -6,7 +6,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Flying</title>
</head>
<body style="background-color: #f5f5f5;">
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -1,4 +1,4 @@
export interface Alert {
severity: 'info' | 'error' | 'success' | 'warning';
severity: 'danger' | 'default' | 'success' | 'warning';
message: string;
}

View File

@@ -5,7 +5,6 @@ import AppContextProvider from './context/appContext/AppContextProvider.tsx';
import LogbookContextProvider from './context/logbookContext/LogbookContextProvider.tsx'
import { BrowserRouter } from 'react-router-dom';
import { OidcProvider } from './auth/oidcConfig.ts';
import './styles.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<OidcProvider>

View File

@@ -1,42 +1,12 @@
@import "tailwindcss";
@plugin "daisyui";
@plugin "daisyui/theme" {
name: "lofi";
default: true;
prefersdark: false;
color-scheme: "light";
--color-base-100: oklch(100% 0 0);
--color-base-200: oklch(97% 0 0);
--color-base-300: oklch(94% 0 0);
--color-base-content: oklch(0% 0 0);
--color-primary: oklch(15.906% 0 0);
--color-primary-content: oklch(100% 0 0);
--color-secondary: oklch(21.455% 0.001 17.278);
--color-secondary-content: oklch(100% 0 0);
--color-accent: oklch(26.861% 0 0);
--color-accent-content: oklch(100% 0 0);
--color-neutral: oklch(0% 0 0);
--color-neutral-content: oklch(100% 0 0);
--color-info: oklch(79.54% 0.103 205.9);
--color-info-content: oklch(15.908% 0.02 205.9);
--color-success: oklch(90.13% 0.153 164.14);
--color-success-content: oklch(18.026% 0.03 164.14);
--color-warning: oklch(88.37% 0.135 79.94);
--color-warning-content: oklch(17.674% 0.027 79.94);
--color-error: oklch(78.66% 0.15 28.47);
--color-error-content: oklch(15.732% 0.03 28.47);
--radius-selector: 0.5rem;
--radius-field: 0.5rem;
--radius-box: 0.5rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 0;
--noise: 0;
}
@plugin './hero.ts';
/* Note: You may need to change the path to fit your project structure */
@source '../../node_modules/@heroui/theme/dist/**/*.{js,ts,jsx,tsx}';
@custom-variant dark (&:is(.dark *));
@plugin "@tailwindcss/typography";
body {
background-color: #f5f5f5;
min-height: 100vh;
}
@theme inline {
--color-primary: #000000;
}

View File

@@ -3,9 +3,8 @@ import '@tanstack/react-table';
/* eslint-disable */
declare module '@tanstack/react-table' {
interface ColumnMeta<TData extends RowData, TValue> {
align?: 'text-left' | 'text-center' | 'text-right';
className?: string;
headerAlign?: 'text-left' | 'text-center' | 'text-right';
align?: 'left' | 'center' | 'right';
headerAlign?: 'left' | 'center' | 'right';
}
}
/* eslint-enable */

View File

@@ -1,11 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_BASE_URL: string;
readonly VITE_API_URL: string;
readonly VITE_CLIENT_ID: string;
readonly VITE_ISSUER_URI: string;
readonly VITE_TENANT_ID: string;
readonly VITE_USERINFO_ENDPOINT: string;
readonly VITE_REDIRECT_URL: string;
}
interface ImportMeta {

View File

@@ -13,14 +13,13 @@ export default defineConfig({
strictPort: true
},
server: {
host: true,
port: 8080,
proxy: {
'/api': {
target: 'http://localhost:3000',
changeOrigin: true,
secure: false,
rewrite: (path) => path.replace(/^\/api/, '')
// rewrite: (path) => path.replace(/^\/api/, '')
}
}
}

Binary file not shown.

View File

@@ -1,4 +1,4 @@
dbs:
- path: /mnt/data/flying.db
- path: /var/lib/data/flying.db
replicas:
- path: /mnt/data/backup/flying.db
- path: /mnt/backup/flying.db

View File

@@ -3,39 +3,24 @@ version: '3.8'
services:
azurite:
container_name: azurite-flying
image: mcr.microsoft.com/azure-storage/azurite
image: mcr.microsoft.com/azure-storage/azurite
ports:
- '10000:10000'
- '10001:10001'
- '10002:10002'
command: 'azurite --blobHost 0.0.0.0 --queueHost 0.0.0.0 --tableHost 0.0.0.0 --loose --skipApiVersionCheck'
healthcheck:
test: ["CMD", "nc", "-z", "127.0.0.1", "10000"]
volumes:
- ./azurite-data:/data
seed:
image: mcr.microsoft.com/azure-cli
depends_on:
azurite:
condition: service_healthy
volumes:
- ./seed-data:/data
entrypoint: >
sh -c "
az storage container create --name mycontainer --connection-string 'DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://azurite:10000/devstoreaccount1;' &&
az storage blob upload-batch --destination mycontainer --source /data --connection-string 'DefaultEndpointsProtocol=http;AccountName=devstoreaccount1;AccountKey=Eby8vdM02xNOcqFlqUwJPLlmEtlCDXJ1OUzFT50uSRZ6IFsuFq2UVErCz4I6tq/K1SZFPTOtr/KBHBeksoGMGw==;BlobEndpoint=http://azurite:10000/devstoreaccount1;'
"
restore:
container_name: restore
image: litestream/litestream:0.3.13
volumes:
- ./database/flying.db:/mnt/data/flying.db
- ./database/flying.db:/var/lib/data/flying.db
- ./database/litestream/litestream.yml:/mnt/litestream/litestream.yml
- backup:/mnt/data/backup
- data:/mnt/data
command: restore -config /mnt/litestream/litestream.yml -if-db-not-exists -if-replica-exists /mnt/data/flying.db
- data:/var/lib/data
command: restore -config /mnt/litestream/litestream.yml -if-db-not-exists -if-replica-exists /var/lib/data/flying.db
app:
container_name: flying
@@ -46,9 +31,9 @@ services:
env_file:
- ./api/.env
environment:
- DB_PATH=../../mnt/data/flying.db
- DB_PATH=../../var/lib/data/flying.db
volumes:
- data:/mnt/data
- data:/var/lib/data
depends_on:
restore:
condition: service_completed_successfully
@@ -59,14 +44,13 @@ services:
volumes:
- ./database/litestream/litestream.yml:/mnt/litestream/litestream.yml
- backup:/mnt
- data:/mnt/data
- data:/var/lib/data
command: replicate -config /mnt/litestream/litestream.yml
depends_on:
app:
condition: service_started
volumes:
azurite-data:
# azurite-data:
backup:
data:
seed-data:
data:

View File

@@ -1,5 +1,4 @@
# Terraform
.terraform/*
*.tfstate
*.tfstate.*
.DS_Store
*.tfstate.*

View File

@@ -1,25 +1,6 @@
# This file is maintained automatically by "terraform init".
# Manual edits may be lost in future updates.
provider "registry.terraform.io/hashicorp/azuread" {
version = "3.7.0"
hashes = [
"h1:+DHwtiYCOYEhLMot5KQ0hm+zopmUJwPJ/gJiXVfdNOs=",
"zh:01114beb07eb8415a563ca80a513e7fd11fd6f0670bdd960e9092e58464ab2b7",
"zh:1460c790ca28cc11c3efd4abec076fc5cdd4ae1cc654a45c69e10ca064e0a611",
"zh:161b6dd82cf279e7ac7a31c404f2ace491a5a9e7ec382ace91de2fd18f8c190b",
"zh:1c3e89cf19118fc07d7b04257251fc9897e722c16e0a0df7b07fcd261f8c12e7",
"zh:2eff0bea92c48be0221863ebf7752f491232088f4ec3cbe4da2ebf9f478fed1f",
"zh:69a689198fcac054da30fea6a8abce2f12b667f6f327c9bb388f2c307ee782fd",
"zh:6f5acf42d71a2f5157c54e67b2db9b080c4ebb27b812112c0c84241e66d94803",
"zh:8e0a58ff2c21aff372c66c679db48578be48e99f5cf6727f082cf4b629dbda6c",
"zh:9c88cc06ba7ee1f6e6b43af7e223c4d107fb77e5a984f1441fffb00c3693537f",
"zh:e8656e8143baac71029857c72899f8503cede41f2559c89ce29d21c0436c96fc",
"zh:e8e21913956a3f043c0e65cc95278bdc82f62ff33fd75a56b1919bd63fd14f8a",
"zh:f7f213dd61d1c75b8397d97660cf329870a241e88ec6e0dc07c60725d975252e",
]
}
provider "registry.terraform.io/hashicorp/azurerm" {
version = "4.40.0"
constraints = "4.40.0"
@@ -39,22 +20,3 @@ provider "registry.terraform.io/hashicorp/azurerm" {
"zh:f6fd85bfca8516f9f3840acc3f0e5e335f6195b66231c2db05c0fb43dd8aebe1",
]
}
provider "registry.terraform.io/hashicorp/random" {
version = "3.7.2"
hashes = [
"h1:KG4NuIBl1mRWU0KD/BGfCi1YN/j3F7H4YgeeM7iSdNs=",
"zh:14829603a32e4bc4d05062f059e545a91e27ff033756b48afbae6b3c835f508f",
"zh:1527fb07d9fea400d70e9e6eb4a2b918d5060d604749b6f1c361518e7da546dc",
"zh:1e86bcd7ebec85ba336b423ba1db046aeaa3c0e5f921039b3f1a6fc2f978feab",
"zh:24536dec8bde66753f4b4030b8f3ef43c196d69cccbea1c382d01b222478c7a3",
"zh:29f1786486759fad9b0ce4fdfbbfece9343ad47cd50119045075e05afe49d212",
"zh:4d701e978c2dd8604ba1ce962b047607701e65c078cb22e97171513e9e57491f",
"zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3",
"zh:7b8434212eef0f8c83f5a90c6d76feaf850f6502b61b53c329e85b3b281cba34",
"zh:ac8a23c212258b7976e1621275e3af7099e7e4a3d4478cf8d5d2a27f3bc3e967",
"zh:b516ca74431f3df4c6cf90ddcdb4042c626e026317a33c53f0b445a3d93b720d",
"zh:dc76e4326aec2490c1600d6871a95e78f9050f9ce427c71707ea412a2f2f1a62",
"zh:eac7b63e86c749c7d48f527671c7aee5b4e26c10be6ad7232d6860167f99dbb0",
]
}

View File

@@ -4,16 +4,6 @@ locals {
prod = "flying-prod"
}
app_reg_name = {
test = "noahspan-flying-test"
prod = "noahspan-flying-prod"
}
app_reg_federated_identity_credential_subject = {
test = "repo:noahspannbauer/noahspan-flying:environment:test"
prod = "repo:noahspannbauer/noahspan-flying:environment:prod"
}
container_image = {
test = "noahspan/flying-app:v2.0.0-alpha"
prod = "noahspan/flying-app:v2.0.0-alpha"

Some files were not shown because too many files have changed in this diff Show More