add web frontend from standalone repo
80
web/.eslintrc.cjs
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* This is intended to be a basic starting point for linting in your app.
|
||||||
|
* It relies on recommended configs out of the box for simplicity, but you can
|
||||||
|
* and should modify this configuration to best suit your team's needs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** @type {import('eslint').Linter.Config} */
|
||||||
|
module.exports = {
|
||||||
|
root: true,
|
||||||
|
parserOptions: {
|
||||||
|
ecmaVersion: "latest",
|
||||||
|
sourceType: "module",
|
||||||
|
ecmaFeatures: {
|
||||||
|
jsx: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
env: {
|
||||||
|
browser: true,
|
||||||
|
commonjs: true,
|
||||||
|
es6: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
// Base config
|
||||||
|
extends: ["eslint:recommended"],
|
||||||
|
|
||||||
|
overrides: [
|
||||||
|
// React
|
||||||
|
{
|
||||||
|
files: ["**/*.{js,jsx,ts,tsx}"],
|
||||||
|
plugins: ["react", "jsx-a11y"],
|
||||||
|
extends: [
|
||||||
|
"plugin:react/recommended",
|
||||||
|
"plugin:react/jsx-runtime",
|
||||||
|
"plugin:react-hooks/recommended",
|
||||||
|
"plugin:jsx-a11y/recommended",
|
||||||
|
],
|
||||||
|
settings: {
|
||||||
|
react: {
|
||||||
|
version: "detect",
|
||||||
|
},
|
||||||
|
formComponents: ["Form"],
|
||||||
|
linkComponents: [
|
||||||
|
{ name: "Link", linkAttribute: "to" },
|
||||||
|
{ name: "NavLink", linkAttribute: "to" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Typescript
|
||||||
|
{
|
||||||
|
files: ["**/*.{ts,tsx}"],
|
||||||
|
plugins: ["@typescript-eslint", "import"],
|
||||||
|
parser: "@typescript-eslint/parser",
|
||||||
|
settings: {
|
||||||
|
"import/internal-regex": "^~/",
|
||||||
|
"import/resolver": {
|
||||||
|
node: {
|
||||||
|
extensions: [".ts", ".tsx"],
|
||||||
|
},
|
||||||
|
typescript: {
|
||||||
|
alwaysTryTypes: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
extends: [
|
||||||
|
"plugin:@typescript-eslint/recommended",
|
||||||
|
"plugin:import/recommended",
|
||||||
|
"plugin:import/typescript",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
// Node
|
||||||
|
{
|
||||||
|
files: [".eslintrc.js"],
|
||||||
|
env: {
|
||||||
|
node: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
6
web/.gitignore
vendored
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
/.cache
|
||||||
|
/build
|
||||||
|
/public/build
|
||||||
|
.env
|
15
web/.vscode/launch.json
vendored
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"command": "npm run dev",
|
||||||
|
"name": "Run npm run dev",
|
||||||
|
"type": "node-terminal",
|
||||||
|
"request": "launch",
|
||||||
|
"cwd": "${workspaceFolder}"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
21
web/LICENSE
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 April Petersen
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
84
web/README.md
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
<p align="center">
|
||||||
|
<a href="" rel="nooperner">
|
||||||
|
<img width=200px height=200px src="public/logo.png" alt="Tailfin Logo"></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h1 align="center">Tailfin</h2>
|
||||||
|
|
||||||
|
<h3 align="center">A self-hosted digital flight logbook</h3>
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<a href="LICENSE"><img src="https://img.shields.io/github/license/azpsen/tailfin-web?style=for-the-badge" /></a>
|
||||||
|
<a href="https://www.typescriptlang.org/"><img src="https://img.shields.io/badge/typescript-%23007ACC.svg?style=for-the-badge&logo=typescript&logoColor=white" alt="TypeScript" /></a>
|
||||||
|
<a href="https://react.dev/"><img src="https://img.shields.io/badge/react-%2320232a.svg?style=for-the-badge&logo=react&logoColor=%2361DAFB" alt="React" /></a>
|
||||||
|
<a href="https://remix.run/"><img src="https://img.shields.io/badge/remix-%23000.svg?style=for-the-badge&logo=remix&logoColor=white" alt="Remix-Run" /></a>
|
||||||
|
<a href="https://tanstack.com/query/latest/"><img src="https://img.shields.io/badge/-React%20Query-FF4154?style=for-the-badge&logo=react%20query&logoColor=white" alt="TanStack Query" /></a>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
- [About](#about)
|
||||||
|
- [Getting Started](#getting_started)
|
||||||
|
- [Prerequisites](#prerequisites)
|
||||||
|
- [Installation](#installation)
|
||||||
|
- [Configuration](#configuration)
|
||||||
|
- [Usage](#usage)
|
||||||
|
- [Roadmap](#roadmap)
|
||||||
|
|
||||||
|
## About <a name="about"></a>
|
||||||
|
|
||||||
|
Tailfin is a digital flight logbook designed to be hosted on a personal server, computer, or cloud solution. This is the
|
||||||
|
web frontend.
|
||||||
|
|
||||||
|
I created this because I was disappointed with the options available for digital logbooks. The one provided by
|
||||||
|
ForeFlight is likely most commonly used, but my proclivity towards self-hosting drove me to seek out another solution.
|
||||||
|
Since I could not find any ready-made self-hosted logbooks, I decided to make my own.
|
||||||
|
|
||||||
|
## Getting Started <a name="getting_started"></a>
|
||||||
|
|
||||||
|
### Prerequisites <a name="prerequisites"></a>
|
||||||
|
|
||||||
|
- npm
|
||||||
|
- [tailfin-api](https://github.com/azpsen/tailfin-api)
|
||||||
|
|
||||||
|
### Installation <a name="installation"></a>
|
||||||
|
|
||||||
|
1. Clone the repo
|
||||||
|
|
||||||
|
```
|
||||||
|
$ git clone https://git.github.com/azpsen/tailfin-web.git
|
||||||
|
$ cd tailfin-web
|
||||||
|
```
|
||||||
|
|
||||||
|
3. Install NPM requirements
|
||||||
|
|
||||||
|
```
|
||||||
|
$ npm install
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Build and run the web app
|
||||||
|
|
||||||
|
```
|
||||||
|
$ npm run build && npm run start
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration <a name="configuration"></a>
|
||||||
|
|
||||||
|
The URL for the Tailfin API can be set with the environment variable `TAILFIN_API_URL`. It defaults to `http://localhost:8081`, which assumes the API runs on the same machine and uses the default port.
|
||||||
|
|
||||||
|
## Usage <a name="usage"></a>
|
||||||
|
|
||||||
|
Once running, the web app can be accessed at `localhost:3000`
|
||||||
|
|
||||||
|
## Roadmap <a name="roadmap"></a>
|
||||||
|
|
||||||
|
- [x] Create, view, edit, and delete flight logs
|
||||||
|
- [x] Aircraft managment and association with flight logs
|
||||||
|
- [x] Dashboard with statistics
|
||||||
|
- [x] Attach photos to log entries
|
||||||
|
- [ ] GPS track recording and map display
|
||||||
|
- [ ] Calendar view
|
||||||
|
- [ ] Admin dashboard to manage all users and server configuration
|
||||||
|
- [ ] Integrate database of airports and waypoints that can be queried to find nearest
|
18
web/app/entry.client.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
/**
|
||||||
|
* By default, Remix will handle hydrating your app on the client for you.
|
||||||
|
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
|
||||||
|
* For more information, see https://remix.run/file-conventions/entry.client
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { RemixBrowser } from "@remix-run/react";
|
||||||
|
import { startTransition, StrictMode } from "react";
|
||||||
|
import { hydrateRoot } from "react-dom/client";
|
||||||
|
|
||||||
|
startTransition(() => {
|
||||||
|
hydrateRoot(
|
||||||
|
document,
|
||||||
|
<StrictMode>
|
||||||
|
<RemixBrowser />
|
||||||
|
</StrictMode>
|
||||||
|
);
|
||||||
|
});
|
140
web/app/entry.server.tsx
Normal file
@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* By default, Remix will handle generating the HTTP Response for you.
|
||||||
|
* You are free to delete this file if you'd like to, but if you ever want it revealed again, you can run `npx remix reveal` ✨
|
||||||
|
* For more information, see https://remix.run/file-conventions/entry.server
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { PassThrough } from "node:stream";
|
||||||
|
|
||||||
|
import type { AppLoadContext, EntryContext } from "@remix-run/node";
|
||||||
|
import { createReadableStreamFromReadable } from "@remix-run/node";
|
||||||
|
import { RemixServer } from "@remix-run/react";
|
||||||
|
import isbot from "isbot";
|
||||||
|
import { renderToPipeableStream } from "react-dom/server";
|
||||||
|
|
||||||
|
const ABORT_DELAY = 5_000;
|
||||||
|
|
||||||
|
export default function handleRequest(
|
||||||
|
request: Request,
|
||||||
|
responseStatusCode: number,
|
||||||
|
responseHeaders: Headers,
|
||||||
|
remixContext: EntryContext,
|
||||||
|
// This is ignored so we can keep it in the template for visibility. Feel
|
||||||
|
// free to delete this parameter in your app if you're not using it!
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
loadContext: AppLoadContext
|
||||||
|
) {
|
||||||
|
return isbot(request.headers.get("user-agent"))
|
||||||
|
? handleBotRequest(
|
||||||
|
request,
|
||||||
|
responseStatusCode,
|
||||||
|
responseHeaders,
|
||||||
|
remixContext
|
||||||
|
)
|
||||||
|
: handleBrowserRequest(
|
||||||
|
request,
|
||||||
|
responseStatusCode,
|
||||||
|
responseHeaders,
|
||||||
|
remixContext
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBotRequest(
|
||||||
|
request: Request,
|
||||||
|
responseStatusCode: number,
|
||||||
|
responseHeaders: Headers,
|
||||||
|
remixContext: EntryContext
|
||||||
|
) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let shellRendered = false;
|
||||||
|
const { pipe, abort } = renderToPipeableStream(
|
||||||
|
<RemixServer
|
||||||
|
context={remixContext}
|
||||||
|
url={request.url}
|
||||||
|
abortDelay={ABORT_DELAY}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
onAllReady() {
|
||||||
|
shellRendered = true;
|
||||||
|
const body = new PassThrough();
|
||||||
|
const stream = createReadableStreamFromReadable(body);
|
||||||
|
|
||||||
|
responseHeaders.set("Content-Type", "text/html");
|
||||||
|
|
||||||
|
resolve(
|
||||||
|
new Response(stream, {
|
||||||
|
headers: responseHeaders,
|
||||||
|
status: responseStatusCode,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
pipe(body);
|
||||||
|
},
|
||||||
|
onShellError(error: unknown) {
|
||||||
|
reject(error);
|
||||||
|
},
|
||||||
|
onError(error: unknown) {
|
||||||
|
responseStatusCode = 500;
|
||||||
|
// Log streaming rendering errors from inside the shell. Don't log
|
||||||
|
// errors encountered during initial shell rendering since they'll
|
||||||
|
// reject and get logged in handleDocumentRequest.
|
||||||
|
if (shellRendered) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
setTimeout(abort, ABORT_DELAY);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBrowserRequest(
|
||||||
|
request: Request,
|
||||||
|
responseStatusCode: number,
|
||||||
|
responseHeaders: Headers,
|
||||||
|
remixContext: EntryContext
|
||||||
|
) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
let shellRendered = false;
|
||||||
|
const { pipe, abort } = renderToPipeableStream(
|
||||||
|
<RemixServer
|
||||||
|
context={remixContext}
|
||||||
|
url={request.url}
|
||||||
|
abortDelay={ABORT_DELAY}
|
||||||
|
/>,
|
||||||
|
{
|
||||||
|
onShellReady() {
|
||||||
|
shellRendered = true;
|
||||||
|
const body = new PassThrough();
|
||||||
|
const stream = createReadableStreamFromReadable(body);
|
||||||
|
|
||||||
|
responseHeaders.set("Content-Type", "text/html");
|
||||||
|
|
||||||
|
resolve(
|
||||||
|
new Response(stream, {
|
||||||
|
headers: responseHeaders,
|
||||||
|
status: responseStatusCode,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
pipe(body);
|
||||||
|
},
|
||||||
|
onShellError(error: unknown) {
|
||||||
|
reject(error);
|
||||||
|
},
|
||||||
|
onError(error: unknown) {
|
||||||
|
responseStatusCode = 500;
|
||||||
|
// Log streaming rendering errors from inside the shell. Don't log
|
||||||
|
// errors encountered during initial shell rendering since they'll
|
||||||
|
// reject and get logged in handleDocumentRequest.
|
||||||
|
if (shellRendered) {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
setTimeout(abort, ABORT_DELAY);
|
||||||
|
});
|
||||||
|
}
|
135
web/app/root.tsx
Normal file
@ -0,0 +1,135 @@
|
|||||||
|
import "@mantine/core/styles.css";
|
||||||
|
import "@mantine/dates/styles.css";
|
||||||
|
import "@mantine/carousel/styles.css";
|
||||||
|
|
||||||
|
import { cssBundleHref } from "@remix-run/css-bundle";
|
||||||
|
import type { LinksFunction } from "@remix-run/node";
|
||||||
|
import {
|
||||||
|
Link,
|
||||||
|
Links,
|
||||||
|
LiveReload,
|
||||||
|
Meta,
|
||||||
|
Outlet,
|
||||||
|
Scripts,
|
||||||
|
ScrollRestoration,
|
||||||
|
isRouteErrorResponse,
|
||||||
|
json,
|
||||||
|
useLoaderData,
|
||||||
|
useRouteError,
|
||||||
|
} from "@remix-run/react";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
ColorSchemeScript,
|
||||||
|
Container,
|
||||||
|
Group,
|
||||||
|
MantineProvider,
|
||||||
|
Stack,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { ModalsProvider } from "@mantine/modals";
|
||||||
|
import { IconRocket } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
import { AuthProvider } from "./util/auth";
|
||||||
|
import { ApiProvider } from "./util/api";
|
||||||
|
|
||||||
|
export const links: LinksFunction = () => [
|
||||||
|
{
|
||||||
|
rel: "apple-touch-icon",
|
||||||
|
href: "/favicon/apple-touch-icon.png",
|
||||||
|
sizes: "180x180",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rel: "icon",
|
||||||
|
href: "/favicon/favicon-32x32.png",
|
||||||
|
type: "image/png",
|
||||||
|
sizes: "32x32",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
rel: "icon",
|
||||||
|
href: "/favicon/favicon-16x16.png",
|
||||||
|
type: "image/png",
|
||||||
|
sizes: "16x16",
|
||||||
|
},
|
||||||
|
{ rel: "manifest", href: "/favicon/site.webmanifest" },
|
||||||
|
...(cssBundleHref ? [{ rel: "stylesheet", href: cssBundleHref }] : []),
|
||||||
|
];
|
||||||
|
|
||||||
|
export function ErrorBoundary() {
|
||||||
|
const error = useRouteError();
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<title>Oops!</title>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<Meta />
|
||||||
|
<Links />
|
||||||
|
<ColorSchemeScript />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<MantineProvider>
|
||||||
|
<Container>
|
||||||
|
<Stack>
|
||||||
|
<Title order={2} pt="xl" style={{ textAlign: "center" }}>
|
||||||
|
{isRouteErrorResponse(error)
|
||||||
|
? `Error ${error.status} - ${error.statusText}`
|
||||||
|
: error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Unknown Error"}
|
||||||
|
</Title>
|
||||||
|
<Group justify="center">
|
||||||
|
<Button
|
||||||
|
leftSection={<IconRocket />}
|
||||||
|
component={Link}
|
||||||
|
to="/logbook"
|
||||||
|
variant="default"
|
||||||
|
>
|
||||||
|
Get me out of here!
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</MantineProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loader() {
|
||||||
|
return json({
|
||||||
|
ENV: {
|
||||||
|
TAILFIN_API_URL: process.env.TAILFIN_API_URL ?? "http://localhost:8081",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const data = useLoaderData<typeof loader>();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charSet="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<Meta />
|
||||||
|
<Links />
|
||||||
|
<ColorSchemeScript />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<ApiProvider apiUrl={data.ENV.TAILFIN_API_URL}>
|
||||||
|
<MantineProvider theme={{ primaryColor: "violet" }}>
|
||||||
|
<ModalsProvider>
|
||||||
|
<AuthProvider>
|
||||||
|
<Outlet />
|
||||||
|
<ScrollRestoration />
|
||||||
|
<Scripts />
|
||||||
|
<LiveReload />
|
||||||
|
</AuthProvider>
|
||||||
|
</ModalsProvider>
|
||||||
|
</MantineProvider>
|
||||||
|
</ApiProvider>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
18
web/app/routes/_index.tsx
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { useAuth } from "@/util/auth";
|
||||||
|
import { Outlet, useNavigate } from "@remix-run/react";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
export default function Tailfin() {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && !user) {
|
||||||
|
navigate("/login");
|
||||||
|
} else {
|
||||||
|
navigate("/logbook");
|
||||||
|
}
|
||||||
|
}, [user, loading, navigate]);
|
||||||
|
|
||||||
|
return <Outlet />;
|
||||||
|
}
|
13
web/app/routes/logbook/admin.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Container, Group, Title } from "@mantine/core";
|
||||||
|
|
||||||
|
export default function Admin() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Container>
|
||||||
|
<Group justify="space-between" align="center" grow my="lg">
|
||||||
|
<Title order={2}>Admin</Title>
|
||||||
|
</Group>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
221
web/app/routes/logbook/aircraft.tsx
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
import ErrorDisplay from "@/ui/error-display";
|
||||||
|
import AircraftForm from "@/ui/form/aircraft-form";
|
||||||
|
import { useApi } from "@/util/api";
|
||||||
|
import { useAircraft } from "@/util/hooks";
|
||||||
|
import { AircraftFormSchema, AircraftSchema } from "@/util/types";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Center,
|
||||||
|
Container,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
ScrollArea,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { randomId, useDisclosure } from "@mantine/hooks";
|
||||||
|
import { IconPencil, IconPlus, IconTrash, IconX } from "@tabler/icons-react";
|
||||||
|
import {
|
||||||
|
UseQueryResult,
|
||||||
|
useMutation,
|
||||||
|
useQueryClient,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
|
||||||
|
function AircraftCard({ aircraft }: { aircraft: AircraftSchema }) {
|
||||||
|
const [deleteOpened, { open: openDelete, close: closeDelete }] =
|
||||||
|
useDisclosure(false);
|
||||||
|
|
||||||
|
const client = useApi();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const deleteAircraft = useMutation({
|
||||||
|
mutationFn: async () =>
|
||||||
|
await client.delete(`/aircraft/${aircraft.id}`).then((res) => res.data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["aircraft-list"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [editOpened, { open: openEdit, close: closeEdit }] =
|
||||||
|
useDisclosure(false);
|
||||||
|
|
||||||
|
const updateAircraft = useMutation({
|
||||||
|
mutationFn: async (values: AircraftFormSchema) =>
|
||||||
|
await client
|
||||||
|
.put(`/aircraft/${aircraft.id}`, values)
|
||||||
|
.then((res) => res.data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["aircraft-list"] });
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
opened={deleteOpened}
|
||||||
|
onClose={closeDelete}
|
||||||
|
title="Delete Aircraft?"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Text>
|
||||||
|
Are you sure you want to delete this aircraft? This action cannot be
|
||||||
|
undone.
|
||||||
|
</Text>
|
||||||
|
{deleteAircraft.isError ? (
|
||||||
|
<Text c="red" fw={700}>
|
||||||
|
{deleteAircraft.error.message}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Button color="red" onClick={() => deleteAircraft.mutate()}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
<Button color="gray" onClick={closeDelete}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
opened={editOpened}
|
||||||
|
onClose={closeEdit}
|
||||||
|
title="Edit Aircraft"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<AircraftForm
|
||||||
|
isError={updateAircraft.isError}
|
||||||
|
error={updateAircraft.error}
|
||||||
|
isPending={updateAircraft.isPending}
|
||||||
|
onSubmit={(values: AircraftFormSchema) =>
|
||||||
|
updateAircraft.mutate(values)
|
||||||
|
}
|
||||||
|
initialValues={aircraft}
|
||||||
|
submitButtonLabel="Update"
|
||||||
|
withCancelButton
|
||||||
|
cancelFunc={closeEdit}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
<Card key={randomId()} withBorder shadow="sm">
|
||||||
|
<Stack>
|
||||||
|
<Group grow justify="space-between">
|
||||||
|
<Title order={4}>{aircraft.tail_no}</Title>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<ActionIcon variant="transparent" onClick={openEdit}>
|
||||||
|
<IconPencil />
|
||||||
|
</ActionIcon>
|
||||||
|
<ActionIcon
|
||||||
|
variant="transparent"
|
||||||
|
color="red"
|
||||||
|
onClick={openDelete}
|
||||||
|
>
|
||||||
|
<IconTrash />
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
<Group>
|
||||||
|
<Text>{aircraft.make}</Text>
|
||||||
|
<Text>{aircraft.model}</Text>
|
||||||
|
</Group>
|
||||||
|
<Group>
|
||||||
|
<Text>{aircraft.aircraft_category}</Text>
|
||||||
|
<Text>/</Text>
|
||||||
|
<Text>{aircraft.aircraft_class}</Text>
|
||||||
|
</Group>
|
||||||
|
{aircraft.hobbs ? <Text>Hobbs: {aircraft.hobbs}</Text> : null}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewAircraftModal({
|
||||||
|
opened,
|
||||||
|
close,
|
||||||
|
}: {
|
||||||
|
opened: boolean;
|
||||||
|
close: () => void;
|
||||||
|
}) {
|
||||||
|
const client = useApi();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const addAircraft = useMutation({
|
||||||
|
mutationFn: async (values: AircraftFormSchema) => {
|
||||||
|
const newAircraft = values;
|
||||||
|
if (newAircraft) {
|
||||||
|
const res = await client.post("/aircraft", newAircraft);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
throw new Error("Aircraft creation failed");
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["aircraft-list"] });
|
||||||
|
close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal opened={opened} onClose={close} title="New Aircraft" centered>
|
||||||
|
<AircraftForm
|
||||||
|
onSubmit={addAircraft.mutate}
|
||||||
|
isError={addAircraft.isError}
|
||||||
|
error={addAircraft.error}
|
||||||
|
isPending={addAircraft.isPending}
|
||||||
|
submitButtonLabel="Add"
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Aircraft() {
|
||||||
|
const aircraft: UseQueryResult<AircraftSchema[]> = useAircraft();
|
||||||
|
|
||||||
|
const [newOpened, { open: openNew, close: closeNew }] = useDisclosure(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<NewAircraftModal opened={newOpened} close={closeNew} />
|
||||||
|
<Container>
|
||||||
|
<Group justify="space-between" align="center" grow my="lg">
|
||||||
|
<Title order={2}>Aircraft</Title>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
<Tooltip label="Add Aircraft">
|
||||||
|
<ActionIcon variant="subtle" onClick={openNew}>
|
||||||
|
<IconPlus />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
<ScrollArea h="calc(100vh - 95px - 75px)">
|
||||||
|
{aircraft.isLoading ? (
|
||||||
|
<Center h="calc(100vh - 95px - 75px)">
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
) : aircraft.isError ? (
|
||||||
|
<Center h="calc(100vh - 95px - 75px)">
|
||||||
|
<ErrorDisplay error={aircraft.error?.message} />
|
||||||
|
</Center>
|
||||||
|
) : aircraft.data && aircraft.data.length === 0 ? (
|
||||||
|
<Center h="calc(100vh - 95px - 75px)">
|
||||||
|
<Stack align="center">
|
||||||
|
<IconX size="3rem" />
|
||||||
|
<Text c="dimmed">No Aircraft</Text>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
) : (
|
||||||
|
<Stack justify="center">
|
||||||
|
{aircraft.data?.map((item) => (
|
||||||
|
<AircraftCard key={randomId()} aircraft={item} />
|
||||||
|
))}
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
153
web/app/routes/logbook/dashboard.tsx
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
import CollapsibleFieldset from "@/ui/display/collapsible-fieldset";
|
||||||
|
import { VerticalLogItem } from "@/ui/display/log-item";
|
||||||
|
import ErrorDisplay from "@/ui/error-display";
|
||||||
|
import { useApi } from "@/util/api";
|
||||||
|
import {
|
||||||
|
Center,
|
||||||
|
Text,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Container,
|
||||||
|
Stack,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { randomId } from "@mantine/hooks";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const client = useApi();
|
||||||
|
|
||||||
|
const [totalsData, setTotalsData] = useState<{
|
||||||
|
by_class: object;
|
||||||
|
totals: object;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const totals = useQuery({
|
||||||
|
queryKey: ["totals"],
|
||||||
|
queryFn: async () =>
|
||||||
|
await client.get(`/flights/totals`).then((res) => res.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (totals.isFetched && !!totals.data) {
|
||||||
|
setTotalsData(totals.data);
|
||||||
|
}
|
||||||
|
}, [totals.data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
{totals.isLoading ? (
|
||||||
|
<Center h="calc(100vh - 95px)">
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
) : totals.isError ? (
|
||||||
|
<Center h="calc(100vh - 95px)">
|
||||||
|
<ErrorDisplay error={totals.error?.message} />
|
||||||
|
</Center>
|
||||||
|
) : (
|
||||||
|
<Stack align="center" mt="xl">
|
||||||
|
<Title order={3}>Totals</Title>
|
||||||
|
<CollapsibleFieldset legend="Time" w="100%">
|
||||||
|
<Group grow>
|
||||||
|
<VerticalLogItem
|
||||||
|
label="All"
|
||||||
|
content={totalsData?.totals?.time_total ?? 0.0}
|
||||||
|
/>
|
||||||
|
<VerticalLogItem
|
||||||
|
label="Solo"
|
||||||
|
content={totalsData?.totals?.time_solo ?? 0.0}
|
||||||
|
/>
|
||||||
|
<VerticalLogItem
|
||||||
|
label="PIC"
|
||||||
|
content={totalsData?.totals?.time_pic ?? 0.0}
|
||||||
|
/>
|
||||||
|
<VerticalLogItem
|
||||||
|
label="SIC"
|
||||||
|
content={totalsData?.totals?.time_sic ?? 0.0}
|
||||||
|
/>
|
||||||
|
<VerticalLogItem
|
||||||
|
label="Instrument"
|
||||||
|
content={totalsData?.totals?.time_instrument ?? 0.0}
|
||||||
|
/>
|
||||||
|
<VerticalLogItem
|
||||||
|
label="Simulator"
|
||||||
|
content={totalsData?.totals?.time_sim ?? 0.0}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</CollapsibleFieldset>
|
||||||
|
<CollapsibleFieldset legend="Landings" w="100%">
|
||||||
|
<Group grow>
|
||||||
|
<VerticalLogItem
|
||||||
|
label="Day"
|
||||||
|
content={totalsData?.totals?.landings_day ?? 0}
|
||||||
|
/>
|
||||||
|
<VerticalLogItem
|
||||||
|
label="Night"
|
||||||
|
content={totalsData?.totals?.landings_night ?? 0}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</CollapsibleFieldset>
|
||||||
|
<CollapsibleFieldset legend="Cross-Country" w="100%">
|
||||||
|
<Group grow>
|
||||||
|
<VerticalLogItem
|
||||||
|
label="Hours"
|
||||||
|
content={totalsData?.totals?.time_xc ?? 0.0}
|
||||||
|
/>
|
||||||
|
<VerticalLogItem
|
||||||
|
label="Dual Recvd"
|
||||||
|
content={totalsData?.totals?.xc_dual_recvd ?? 0.0}
|
||||||
|
/>
|
||||||
|
<VerticalLogItem
|
||||||
|
label="Solo"
|
||||||
|
content={totalsData?.totals?.xc_solo ?? 0.0}
|
||||||
|
/>
|
||||||
|
<VerticalLogItem
|
||||||
|
label="PIC"
|
||||||
|
content={totalsData?.totals?.xc_pic ?? 0.0}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</CollapsibleFieldset>
|
||||||
|
<CollapsibleFieldset legend="Night" w="100%">
|
||||||
|
<Group grow>
|
||||||
|
<VerticalLogItem
|
||||||
|
label="Hours"
|
||||||
|
content={totalsData?.totals?.time_night ?? 0.0}
|
||||||
|
/>
|
||||||
|
<VerticalLogItem
|
||||||
|
label="Night Dual Received"
|
||||||
|
content={totalsData?.totals?.night_dual_recvd ?? 0.0}
|
||||||
|
/>
|
||||||
|
<VerticalLogItem
|
||||||
|
label="Night PIC"
|
||||||
|
content={totalsData?.totals?.night_pic ?? 0.0}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</CollapsibleFieldset>
|
||||||
|
<CollapsibleFieldset legend="By Category / Class" w="100%">
|
||||||
|
<Group justify="center" grow>
|
||||||
|
{totalsData?.by_class?.map((category) => (
|
||||||
|
<Stack key={randomId()} gap="0">
|
||||||
|
<Text pb="xs" style={{ textAlign: "center" }}>
|
||||||
|
{category.aircraft_category}
|
||||||
|
</Text>
|
||||||
|
<Group justify="center" grow>
|
||||||
|
{category.classes.map((total) => (
|
||||||
|
<>
|
||||||
|
<VerticalLogItem
|
||||||
|
key={randomId()}
|
||||||
|
label={total.aircraft_class}
|
||||||
|
content={total.time_total ?? 0.0}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</CollapsibleFieldset>
|
||||||
|
</Stack>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
403
web/app/routes/logbook/flights/$id.tsx
Normal file
@ -0,0 +1,403 @@
|
|||||||
|
import CollapsibleFieldset from "@/ui/display/collapsible-fieldset";
|
||||||
|
import { VerticalLogItem } from "@/ui/display/log-item";
|
||||||
|
import ErrorDisplay from "@/ui/error-display";
|
||||||
|
import { useApi } from "@/util/api";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Center,
|
||||||
|
Container,
|
||||||
|
Grid,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
ScrollArea,
|
||||||
|
Stack,
|
||||||
|
Title,
|
||||||
|
Tooltip,
|
||||||
|
Text,
|
||||||
|
Modal,
|
||||||
|
Button,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import { useNavigate, useParams } from "@remix-run/react";
|
||||||
|
import { IconPencil, IconTrash } from "@tabler/icons-react";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
import { AircraftLogItem } from "@/ui/display/editable/aircraft-log-item";
|
||||||
|
import { DateLogItem } from "@/ui/display/editable/date-log-item";
|
||||||
|
import { HourLogItem } from "@/ui/display/editable/hour-log-item";
|
||||||
|
import { IntLogItem } from "@/ui/display/editable/int-log-item";
|
||||||
|
import { ListLogItem } from "@/ui/display/editable/list-log-item";
|
||||||
|
import { TimeLogItem } from "@/ui/display/editable/time-log-item";
|
||||||
|
import { TextLogItem } from "@/ui/display/editable/text-log-item";
|
||||||
|
import ImageLogItem from "@/ui/display/editable/img-log-item";
|
||||||
|
|
||||||
|
export default function Flight() {
|
||||||
|
const params = useParams();
|
||||||
|
|
||||||
|
const client = useApi();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const flight = useQuery({
|
||||||
|
queryKey: [params.id],
|
||||||
|
queryFn: async () =>
|
||||||
|
await client.get(`/flights/${params.id}`).then((res) => res.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [imageIds, setImageIds] = useState<string[]>([]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (flight.data) {
|
||||||
|
setImageIds(flight.data.images ?? []);
|
||||||
|
}
|
||||||
|
}, [flight.data]);
|
||||||
|
|
||||||
|
const [deleteOpened, { open: openDelete, close: closeDelete }] =
|
||||||
|
useDisclosure(false);
|
||||||
|
|
||||||
|
const deleteFlight = useMutation({
|
||||||
|
mutationFn: async () =>
|
||||||
|
await client.delete(`/flights/${params.id}`).then((res) => res.data),
|
||||||
|
onSuccess: () => {
|
||||||
|
navigate("/logbook/flights");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const log = flight.data;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
opened={deleteOpened}
|
||||||
|
onClose={closeDelete}
|
||||||
|
title="Delete Flight?"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<Text>
|
||||||
|
Are you sure you want to delete this flight? This action cannot be
|
||||||
|
undone.
|
||||||
|
</Text>
|
||||||
|
{deleteFlight.isError ? (
|
||||||
|
<Text c="red" fw={700}>
|
||||||
|
{deleteFlight.error.message}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
<Group justify="flex-end">
|
||||||
|
{deleteFlight.isPending ? <Loader /> : null}
|
||||||
|
<Button color="red" onClick={() => deleteFlight.mutate()}>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
<Button color="gray" onClick={closeDelete}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
<Container>
|
||||||
|
<Stack h="calc(100vh-95px)">
|
||||||
|
{flight.isError ? (
|
||||||
|
<Center h="calc(100vh - 95px)">
|
||||||
|
<ErrorDisplay error="Error Fetching Flight" />
|
||||||
|
</Center>
|
||||||
|
) : flight.isPending ? (
|
||||||
|
<Center h="calc(100vh - 95px)">
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
) : flight.data ? (
|
||||||
|
<>
|
||||||
|
<Group justify="space-between" px="xl">
|
||||||
|
<Title order={2} py="lg" style={{ textAlign: "center" }}>
|
||||||
|
Flight Log
|
||||||
|
</Title>
|
||||||
|
<Group>
|
||||||
|
<Tooltip
|
||||||
|
label="Edit Flight"
|
||||||
|
onClick={() =>
|
||||||
|
navigate(`/logbook/flights/edit/${params.id}`)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ActionIcon variant="subtle" size="lg">
|
||||||
|
<IconPencil />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label="Delete Flight">
|
||||||
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size="lg"
|
||||||
|
color="red"
|
||||||
|
onClick={openDelete}
|
||||||
|
>
|
||||||
|
<IconTrash />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
</Group>
|
||||||
|
<ScrollArea h="calc(100vh - 95px - 110px)" m="0" p="0">
|
||||||
|
<Container h="100%">
|
||||||
|
<Grid justify="center">
|
||||||
|
{imageIds.length > 0 ? (
|
||||||
|
<CollapsibleFieldset legend="Images" mt="sm" w="100%">
|
||||||
|
<ImageLogItem
|
||||||
|
imageIds={imageIds}
|
||||||
|
id={params.id ?? ""}
|
||||||
|
mah="700px"
|
||||||
|
/>
|
||||||
|
</CollapsibleFieldset>
|
||||||
|
) : null}
|
||||||
|
<CollapsibleFieldset legend="About" mt="sm" w="100%">
|
||||||
|
<Group grow>
|
||||||
|
<DateLogItem
|
||||||
|
label="Date"
|
||||||
|
content={log.date}
|
||||||
|
id={params.id}
|
||||||
|
field="date"
|
||||||
|
/>
|
||||||
|
<AircraftLogItem
|
||||||
|
label="Aircraft"
|
||||||
|
content={log.aircraft}
|
||||||
|
id={params.id}
|
||||||
|
field="aircraft"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
{(log.pax || log.crew) &&
|
||||||
|
(log.pax.length > 0 || log.crew.length > 0) ? (
|
||||||
|
<Group grow mt="sm">
|
||||||
|
<ListLogItem
|
||||||
|
label="Pax"
|
||||||
|
content={log.pax}
|
||||||
|
listColor="gray"
|
||||||
|
id={params.id}
|
||||||
|
field="pax"
|
||||||
|
/>
|
||||||
|
<ListLogItem
|
||||||
|
label="Crew"
|
||||||
|
content={log.crew}
|
||||||
|
listColor="gray"
|
||||||
|
id={params.id}
|
||||||
|
field="crew"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
|
{log.tags && log.tags.length > 0 ? (
|
||||||
|
<Group grow mt="sm">
|
||||||
|
<ListLogItem
|
||||||
|
label="Tags"
|
||||||
|
content={log.tags}
|
||||||
|
id={params.id}
|
||||||
|
field="tags"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
|
{log.comments?.length > 0 ? (
|
||||||
|
<Group grow mt="sm">
|
||||||
|
<TextLogItem
|
||||||
|
label="Comments"
|
||||||
|
content={log.comments}
|
||||||
|
id={params.id}
|
||||||
|
field="comments"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
|
</CollapsibleFieldset>
|
||||||
|
{log.waypoint_from || log.waypoint_to || log.route ? (
|
||||||
|
<CollapsibleFieldset legend="Route" w="100%" mt="sm">
|
||||||
|
{log.waypoint_from || log.waypoint_to ? (
|
||||||
|
<Group grow>
|
||||||
|
<TextLogItem
|
||||||
|
label="From"
|
||||||
|
content={log.waypoint_from}
|
||||||
|
id={params.id}
|
||||||
|
field="waypoint_from"
|
||||||
|
/>
|
||||||
|
<TextLogItem
|
||||||
|
label="To"
|
||||||
|
content={log.waypoint_to}
|
||||||
|
id={params.id}
|
||||||
|
field="waypoint_to"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
|
{log.route ? (
|
||||||
|
<Group grow>
|
||||||
|
<VerticalLogItem
|
||||||
|
label="Route"
|
||||||
|
content={log.route}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
|
</CollapsibleFieldset>
|
||||||
|
) : null}
|
||||||
|
{log.hobbs_start || log.hobbs_end ? (
|
||||||
|
<CollapsibleFieldset legend="Times" w="100%" mt="sm">
|
||||||
|
<Group grow>
|
||||||
|
<HourLogItem
|
||||||
|
label="Hobbs Start"
|
||||||
|
content={log.hobbs_start}
|
||||||
|
id={params.id}
|
||||||
|
field="hobbs_start"
|
||||||
|
/>
|
||||||
|
<HourLogItem
|
||||||
|
label="Hobbs End"
|
||||||
|
content={log.hobbs_end}
|
||||||
|
id={params.id}
|
||||||
|
field="hobbs_end"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</CollapsibleFieldset>
|
||||||
|
) : null}
|
||||||
|
{log.time_start ||
|
||||||
|
log.time_off ||
|
||||||
|
log.time_down ||
|
||||||
|
log.time_stop ? (
|
||||||
|
<CollapsibleFieldset legend="Start/Stop" w="100%" mt="sm">
|
||||||
|
{log.time_start || log.time_off ? (
|
||||||
|
<Group grow>
|
||||||
|
<TimeLogItem
|
||||||
|
label="Time Start"
|
||||||
|
content={log.time_start}
|
||||||
|
date={log.date}
|
||||||
|
id={params.id}
|
||||||
|
field="time_start"
|
||||||
|
/>
|
||||||
|
<TimeLogItem
|
||||||
|
label="Time Off"
|
||||||
|
content={log.time_off}
|
||||||
|
date={log.date}
|
||||||
|
id={params.id}
|
||||||
|
field="time_off"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
|
{log.time_down || log.time_stop ? (
|
||||||
|
<Group grow mt="sm">
|
||||||
|
<TimeLogItem
|
||||||
|
label="Time Down"
|
||||||
|
content={log.time_down}
|
||||||
|
date={log.date}
|
||||||
|
id={params.id}
|
||||||
|
field="time_down"
|
||||||
|
/>
|
||||||
|
<TimeLogItem
|
||||||
|
label="Time Stop"
|
||||||
|
content={log.time_stop}
|
||||||
|
date={log.date}
|
||||||
|
id={params.id}
|
||||||
|
field="time_stop"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
) : null}
|
||||||
|
</CollapsibleFieldset>
|
||||||
|
) : null}
|
||||||
|
<CollapsibleFieldset legend="Hours" w="100%" mt="sm">
|
||||||
|
<Group grow>
|
||||||
|
<HourLogItem
|
||||||
|
label="Total"
|
||||||
|
content={log.time_total}
|
||||||
|
id={params.id}
|
||||||
|
field="time_total"
|
||||||
|
/>
|
||||||
|
<HourLogItem
|
||||||
|
label="Solo"
|
||||||
|
content={log.time_solo}
|
||||||
|
id={params.id}
|
||||||
|
field="time_solo"
|
||||||
|
/>
|
||||||
|
<HourLogItem
|
||||||
|
label="Night"
|
||||||
|
content={log.time_night}
|
||||||
|
id={params.id}
|
||||||
|
field="time_night"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<Group grow mt="sm">
|
||||||
|
<HourLogItem
|
||||||
|
label="PIC"
|
||||||
|
content={log.time_pic}
|
||||||
|
id={params.id}
|
||||||
|
field="time_pic"
|
||||||
|
/>
|
||||||
|
<HourLogItem
|
||||||
|
label="SIC"
|
||||||
|
content={log.time_sic}
|
||||||
|
id={params.id}
|
||||||
|
field="time_sic"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</CollapsibleFieldset>
|
||||||
|
{log.time_xc || log.dist_xc ? (
|
||||||
|
<CollapsibleFieldset
|
||||||
|
legend="Cross-Country"
|
||||||
|
w="100%"
|
||||||
|
mt="sm"
|
||||||
|
>
|
||||||
|
<Group grow>
|
||||||
|
<HourLogItem
|
||||||
|
label="Hours"
|
||||||
|
content={log.time_xc}
|
||||||
|
id={params.id}
|
||||||
|
field="time_xc"
|
||||||
|
/>
|
||||||
|
<VerticalLogItem
|
||||||
|
label="Distance"
|
||||||
|
content={log.dist_xc}
|
||||||
|
decimal={2}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</CollapsibleFieldset>
|
||||||
|
) : null}
|
||||||
|
<CollapsibleFieldset legend="Landings" w="100%">
|
||||||
|
<Group grow>
|
||||||
|
<IntLogItem
|
||||||
|
label="Day"
|
||||||
|
content={log.landings_day}
|
||||||
|
id={params.id}
|
||||||
|
field="landings_day"
|
||||||
|
/>
|
||||||
|
<IntLogItem
|
||||||
|
label="Night"
|
||||||
|
content={log.landings_night}
|
||||||
|
id={params.id}
|
||||||
|
field="landings_night"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</CollapsibleFieldset>
|
||||||
|
{log.time_instrument ||
|
||||||
|
log.time_sim_instrument ||
|
||||||
|
log.holds_instrument ? (
|
||||||
|
<CollapsibleFieldset legend="Instrument" mt="sm" w="100%">
|
||||||
|
<Group grow>
|
||||||
|
<HourLogItem
|
||||||
|
label="Instrument Time"
|
||||||
|
content={log.time_instrument}
|
||||||
|
id={params.id}
|
||||||
|
field="time_instrument"
|
||||||
|
/>
|
||||||
|
<HourLogItem
|
||||||
|
label="Simulated Instrument Time"
|
||||||
|
content={log.time_sim_instrument}
|
||||||
|
id={params.id}
|
||||||
|
/>
|
||||||
|
<IntLogItem
|
||||||
|
label="Instrument Holds"
|
||||||
|
content={log.holds_instrument}
|
||||||
|
id={params.id}
|
||||||
|
field="holds_instrument"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</CollapsibleFieldset>
|
||||||
|
) : null}
|
||||||
|
</Grid>
|
||||||
|
</Container>
|
||||||
|
</ScrollArea>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Center h="calc(100vh - 95px)">
|
||||||
|
<ErrorDisplay error="Unknown Error" />
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
19
web/app/routes/logbook/flights/_index.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { Center, Container, Stack } from "@mantine/core";
|
||||||
|
import { MobileFlightsList } from "@/routes/logbook/flights/flights-list";
|
||||||
|
import { IconFeather } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
export default function Flights() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Container visibleFrom="lg" h="calc(100vh - 95px)">
|
||||||
|
<Stack align="center" justify="center" h="100%">
|
||||||
|
<IconFeather size="3rem" />
|
||||||
|
<Center>Select a flight</Center>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
<Container hiddenFrom="lg">
|
||||||
|
<MobileFlightsList />
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
111
web/app/routes/logbook/flights/edit/$id.tsx
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
import { Center, Container, Loader, Stack, Title } from "@mantine/core";
|
||||||
|
import {
|
||||||
|
FlightFormSchema,
|
||||||
|
flightCreateHelper,
|
||||||
|
flightEditHelper,
|
||||||
|
} from "@/util/types";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useApi } from "@/util/api";
|
||||||
|
import { useNavigate, useParams } from "@remix-run/react";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
import FlightForm from "@/ui/form/flight-form";
|
||||||
|
import ErrorDisplay from "@/ui/error-display";
|
||||||
|
|
||||||
|
export default function EditFlight() {
|
||||||
|
const params = useParams();
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const client = useApi();
|
||||||
|
|
||||||
|
const flight = useQuery({
|
||||||
|
queryKey: [params.id],
|
||||||
|
queryFn: async () =>
|
||||||
|
await client.get(`/flights/${params.id}`).then((res) => res.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const editFlight = useMutation({
|
||||||
|
mutationFn: async (values: FlightFormSchema) => {
|
||||||
|
const newFlight = flightCreateHelper(values);
|
||||||
|
if (newFlight) {
|
||||||
|
const existing_img = values.existing_images ?? [];
|
||||||
|
const missing = flight.data.images.filter(
|
||||||
|
(item: string) => existing_img?.indexOf(item) < 0
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const img of missing) {
|
||||||
|
await client.delete(`/img/${img}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await client.put(`/flights/${params.id}`, {
|
||||||
|
...newFlight,
|
||||||
|
images: values.existing_images,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload images
|
||||||
|
if (values.images.length > 0) {
|
||||||
|
const imageForm = new FormData();
|
||||||
|
|
||||||
|
for (const img of values.images ?? []) {
|
||||||
|
imageForm.append("images", img);
|
||||||
|
}
|
||||||
|
|
||||||
|
const img_id = await client.post(
|
||||||
|
`/flights/${params.id}/add_images`,
|
||||||
|
imageForm,
|
||||||
|
{ headers: { "Content-Type": "multipart/form-data" } }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!img_id) {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["flights-list"] });
|
||||||
|
throw new Error("Image upload failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
throw new Error("Flight updating failed");
|
||||||
|
},
|
||||||
|
retry: (failureCount, error: AxiosError) => {
|
||||||
|
return !error || error.response?.status !== 401;
|
||||||
|
},
|
||||||
|
onSuccess: async (data: { id: string }) => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["flights-list"] });
|
||||||
|
navigate(`/logbook/flights/${data.id}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Stack>
|
||||||
|
<Title order={2}>Edit Flight</Title>
|
||||||
|
|
||||||
|
{flight.isLoading ? (
|
||||||
|
<Center h="calc(100vh - 95px - 110px)">
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
) : flight.isError ? (
|
||||||
|
<Center h="calc(100vh - 95px - 110px)">
|
||||||
|
<ErrorDisplay error={flight.error.message} />
|
||||||
|
</Center>
|
||||||
|
) : (
|
||||||
|
<FlightForm
|
||||||
|
initialValues={
|
||||||
|
flight.data ? flightEditHelper(flight.data) ?? null : null
|
||||||
|
}
|
||||||
|
onSubmit={editFlight.mutate}
|
||||||
|
isPending={editFlight.isPending}
|
||||||
|
isError={editFlight.isError}
|
||||||
|
error={editFlight.error}
|
||||||
|
submitButtonLabel="Update"
|
||||||
|
withCancelButton
|
||||||
|
cancelFunc={() => navigate(`/logbook/flights/${params.id}`)}
|
||||||
|
mah="calc(100vh - 95px - 110px)"
|
||||||
|
autofillHobbs={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
327
web/app/routes/logbook/flights/flights-list.tsx
Normal file
@ -0,0 +1,327 @@
|
|||||||
|
import ErrorDisplay from "@/ui/error-display";
|
||||||
|
import { useApi } from "@/util/api";
|
||||||
|
import { useAircraft } from "@/util/hooks";
|
||||||
|
import { AircraftSchema, FlightConciseSchema } from "@/util/types";
|
||||||
|
import {
|
||||||
|
NavLink,
|
||||||
|
Text,
|
||||||
|
Button,
|
||||||
|
ScrollArea,
|
||||||
|
Stack,
|
||||||
|
Loader,
|
||||||
|
Center,
|
||||||
|
Badge,
|
||||||
|
Group,
|
||||||
|
Divider,
|
||||||
|
Select,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { randomId } from "@mantine/hooks";
|
||||||
|
import { Link, useLocation, useNavigate, useParams } from "@remix-run/react";
|
||||||
|
import {
|
||||||
|
IconArrowRightTail,
|
||||||
|
IconPlaneTilt,
|
||||||
|
IconPlus,
|
||||||
|
IconX,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
import {
|
||||||
|
UseQueryResult,
|
||||||
|
useQuery,
|
||||||
|
useQueryClient,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
function FlightsListDisplay({
|
||||||
|
flights,
|
||||||
|
}: {
|
||||||
|
flights: UseQueryResult<{
|
||||||
|
[year: string]: {
|
||||||
|
[month: string]: { [day: string]: FlightConciseSchema[] };
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}) {
|
||||||
|
const monthNames = [
|
||||||
|
"January",
|
||||||
|
"February",
|
||||||
|
"March",
|
||||||
|
"April",
|
||||||
|
"May",
|
||||||
|
"June",
|
||||||
|
"July",
|
||||||
|
"August",
|
||||||
|
"September",
|
||||||
|
"October",
|
||||||
|
"November",
|
||||||
|
"December",
|
||||||
|
];
|
||||||
|
|
||||||
|
const params = useParams();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
console.log(params);
|
||||||
|
if (params.id) {
|
||||||
|
const selectedFlight = document.getElementById(`${params.id} navlink`);
|
||||||
|
console.log(selectedFlight);
|
||||||
|
selectedFlight?.scrollIntoView({ block: "center", inline: "center" });
|
||||||
|
}
|
||||||
|
}, [flights.data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{flights.data ? (
|
||||||
|
Object.entries(flights.data)?.length === 0 ? (
|
||||||
|
<Center h="calc(100vh - 95px - 50px)">
|
||||||
|
<Stack align="center">
|
||||||
|
<IconX size="3rem" />
|
||||||
|
<Center>No flights</Center>
|
||||||
|
</Stack>
|
||||||
|
</Center>
|
||||||
|
) : (
|
||||||
|
Object.entries(flights.data)
|
||||||
|
.reverse()
|
||||||
|
.map(([year, months]) => (
|
||||||
|
<>
|
||||||
|
<NavLink
|
||||||
|
key={randomId()}
|
||||||
|
label={`-- ${year} --`}
|
||||||
|
fw={700}
|
||||||
|
style={{ textAlign: "center" }}
|
||||||
|
defaultOpened
|
||||||
|
childrenOffset={0}
|
||||||
|
>
|
||||||
|
<>
|
||||||
|
<Divider />
|
||||||
|
{Object.entries(months)
|
||||||
|
.reverse()
|
||||||
|
.map(([month, days]) => (
|
||||||
|
<NavLink
|
||||||
|
key={randomId()}
|
||||||
|
label={monthNames[Number(month) - 1]}
|
||||||
|
fw={500}
|
||||||
|
style={{ textAlign: "center" }}
|
||||||
|
defaultOpened
|
||||||
|
>
|
||||||
|
<Divider />
|
||||||
|
{Object.entries(days)
|
||||||
|
.reverse()
|
||||||
|
.map(([, logs]) => (
|
||||||
|
<>
|
||||||
|
{logs
|
||||||
|
.reverse()
|
||||||
|
.map((flight: FlightConciseSchema) => (
|
||||||
|
<>
|
||||||
|
<NavLink
|
||||||
|
key={randomId()}
|
||||||
|
id={`${flight.id} navlink`}
|
||||||
|
component={Link}
|
||||||
|
to={`/logbook/flights/${flight.id}`}
|
||||||
|
label={
|
||||||
|
<Group>
|
||||||
|
<Badge
|
||||||
|
color="gray"
|
||||||
|
size="lg"
|
||||||
|
radius="sm"
|
||||||
|
px="xs"
|
||||||
|
>
|
||||||
|
{flight.date}
|
||||||
|
</Badge>
|
||||||
|
<Text fw={500}>
|
||||||
|
{`${Number(
|
||||||
|
flight.time_total
|
||||||
|
).toFixed(1)} hr`}
|
||||||
|
</Text>
|
||||||
|
{flight.waypoint_from ||
|
||||||
|
flight.waypoint_to ? (
|
||||||
|
<>
|
||||||
|
<Text>/</Text>
|
||||||
|
<Group gap="xs">
|
||||||
|
{flight.waypoint_from ? (
|
||||||
|
<Text>
|
||||||
|
{flight.waypoint_from}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
""
|
||||||
|
)}
|
||||||
|
{flight.waypoint_from &&
|
||||||
|
flight.waypoint_to ? (
|
||||||
|
<IconArrowRightTail />
|
||||||
|
) : null}
|
||||||
|
{flight.waypoint_to ? (
|
||||||
|
<Text>
|
||||||
|
{flight.waypoint_to}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
""
|
||||||
|
)}
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<Text lineClamp={1}>
|
||||||
|
{flight.comments
|
||||||
|
? flight.comments
|
||||||
|
: "(No Comment)"}
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
rightSection={
|
||||||
|
flight.aircraft ? (
|
||||||
|
<Badge
|
||||||
|
key={randomId()}
|
||||||
|
leftSection={
|
||||||
|
<IconPlaneTilt size="1rem" />
|
||||||
|
}
|
||||||
|
color="gray"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{flight.aircraft}
|
||||||
|
</Badge>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
active={params.id === flight.id}
|
||||||
|
/>
|
||||||
|
<Divider />
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
))}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
</NavLink>
|
||||||
|
</>
|
||||||
|
))
|
||||||
|
)
|
||||||
|
) : flights.isLoading ? (
|
||||||
|
<Center h="calc(100vh - 95px - 50px)">
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
) : flights.isError ? (
|
||||||
|
<ErrorDisplay error={flights.error?.message} />
|
||||||
|
) : (
|
||||||
|
<Center h="calc(100vh - 95px - 50px)">
|
||||||
|
<Text p="sm">No Flights</Text>
|
||||||
|
</Center>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AircraftFilter({
|
||||||
|
aircraft,
|
||||||
|
setAircraft,
|
||||||
|
query = "flights-list",
|
||||||
|
}: {
|
||||||
|
aircraft: string;
|
||||||
|
setAircraft: (aircraft: string) => void;
|
||||||
|
query?: string;
|
||||||
|
}) {
|
||||||
|
const getAircraft = useAircraft();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Select
|
||||||
|
placeholder="Filter by Aircraft..."
|
||||||
|
data={
|
||||||
|
getAircraft.isFetched
|
||||||
|
? getAircraft.data?.map((item: AircraftSchema) => ({
|
||||||
|
value: item.tail_no,
|
||||||
|
label: item.tail_no,
|
||||||
|
}))
|
||||||
|
: ""
|
||||||
|
}
|
||||||
|
value={aircraft}
|
||||||
|
onChange={(_value, option) => {
|
||||||
|
setAircraft(option?.label ?? "");
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: [query, aircraft],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
clearable
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FlightsList() {
|
||||||
|
const location = useLocation();
|
||||||
|
const page = location.pathname.split("/")[3];
|
||||||
|
|
||||||
|
const [aircraft, setAircraft] = useState("");
|
||||||
|
|
||||||
|
const client = useApi();
|
||||||
|
|
||||||
|
// const flights = useFlights("aircraft", aircraft);
|
||||||
|
const flights = useQuery({
|
||||||
|
queryKey: ["flights-list", aircraft],
|
||||||
|
queryFn: async () =>
|
||||||
|
await client
|
||||||
|
.get(
|
||||||
|
`/flights/by-date?order=1${
|
||||||
|
aircraft !== "" ? `&filter=aircraft&filter_val=${aircraft}` : ""
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
.then((res) => res.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack p="0" m="0" gap="0">
|
||||||
|
<Group grow preventGrowOverflow={false}>
|
||||||
|
<AircraftFilter aircraft={aircraft} setAircraft={setAircraft} />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
leftSection={<IconPlus />}
|
||||||
|
onClick={() => navigate("/logbook/flights/new")}
|
||||||
|
>
|
||||||
|
New Flight
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
<ScrollArea h="calc(100vh - 95px - 50px)">
|
||||||
|
<FlightsListDisplay flights={flights} page={page} />
|
||||||
|
</ScrollArea>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MobileFlightsList() {
|
||||||
|
const [aircraft, setAircraft] = useState("");
|
||||||
|
|
||||||
|
const client = useApi();
|
||||||
|
|
||||||
|
const flights = useQuery({
|
||||||
|
queryKey: ["flights-list", aircraft],
|
||||||
|
queryFn: async () =>
|
||||||
|
await client
|
||||||
|
.get(
|
||||||
|
`/flights/by-date?order=1${
|
||||||
|
aircraft !== "" ? `&filter=aircraft&filter_val=${aircraft}` : ""
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
.then((res) => res.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const scrollAreaRef = useRef(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack p="0" m="0" justify="space-between" h="calc(100vh - 95px)">
|
||||||
|
<ScrollArea h="calc(100vh - 95px - 50px" ref={scrollAreaRef}>
|
||||||
|
<FlightsListDisplay flights={flights} />
|
||||||
|
</ScrollArea>{" "}
|
||||||
|
<Group grow preventGrowOverflow={false} wrap="nowrap">
|
||||||
|
<AircraftFilter aircraft={aircraft} setAircraft={setAircraft} />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
leftSection={<IconPlus />}
|
||||||
|
onClick={() => navigate("/logbook/flights/new")}
|
||||||
|
>
|
||||||
|
Add
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { FlightsList, MobileFlightsList };
|
69
web/app/routes/logbook/flights/new.tsx
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
import { Container, Stack, Title } from "@mantine/core";
|
||||||
|
import { FlightFormSchema, flightCreateHelper } from "@/util/types";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useApi } from "@/util/api";
|
||||||
|
import { useNavigate } from "@remix-run/react";
|
||||||
|
import FlightForm from "@/ui/form/flight-form";
|
||||||
|
|
||||||
|
export default function NewFlight() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const client = useApi();
|
||||||
|
|
||||||
|
const createFlight = useMutation({
|
||||||
|
mutationFn: async (values: FlightFormSchema) => {
|
||||||
|
const newFlight = flightCreateHelper(values);
|
||||||
|
if (newFlight) {
|
||||||
|
const res = await client.post("/flights", newFlight);
|
||||||
|
|
||||||
|
const id = res.data.id;
|
||||||
|
if (!id) throw new Error("Flight creation failed");
|
||||||
|
|
||||||
|
const imageForm = new FormData();
|
||||||
|
|
||||||
|
// Upload images
|
||||||
|
if (values.images.length > 0) {
|
||||||
|
for (const img of values.images) {
|
||||||
|
imageForm.append("images", img);
|
||||||
|
}
|
||||||
|
|
||||||
|
const img_id = await client.post(
|
||||||
|
`/flights/${id}/add_images`,
|
||||||
|
imageForm,
|
||||||
|
{ headers: { "Content-Type": "multipart/form-data" } }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!img_id) {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["flights-list"] });
|
||||||
|
throw new Error("Image upload failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
throw new Error("Flight creation failed");
|
||||||
|
},
|
||||||
|
onSuccess: async (data: { id: string }) => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["flights-list"] });
|
||||||
|
navigate(`/logbook/flights/${data.id}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<Stack>
|
||||||
|
<Title order={2}>New Flight</Title>
|
||||||
|
|
||||||
|
<FlightForm
|
||||||
|
onSubmit={createFlight.mutate}
|
||||||
|
isPending={createFlight.isPending}
|
||||||
|
isError={createFlight.isError}
|
||||||
|
error={createFlight.error}
|
||||||
|
mah="calc(100vh - 95px - 110px)"
|
||||||
|
autofillHobbs
|
||||||
|
/>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
24
web/app/routes/logbook/flights/route.tsx
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { Divider, Grid, Container, ScrollArea } from "@mantine/core";
|
||||||
|
import { Outlet } from "@remix-run/react";
|
||||||
|
import { FlightsList } from "./flights-list";
|
||||||
|
|
||||||
|
export default function FlightsLayout() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Grid h="100%" visibleFrom="lg">
|
||||||
|
<Grid.Col span={4}>
|
||||||
|
<FlightsList />
|
||||||
|
</Grid.Col>
|
||||||
|
<Divider orientation="vertical" m="sm" />
|
||||||
|
<Grid.Col span="auto">
|
||||||
|
<ScrollArea.Autosize mah="calc(100vh - 95px)">
|
||||||
|
<Outlet />
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
</Grid.Col>
|
||||||
|
</Grid>
|
||||||
|
<Container hiddenFrom="lg" style={{ paddingLeft: 0, paddingRight: 0 }}>
|
||||||
|
<Outlet />
|
||||||
|
</Container>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
134
web/app/routes/logbook/me.tsx
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
import ErrorDisplay from "@/ui/error-display";
|
||||||
|
import { useApi } from "@/util/api";
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
Button,
|
||||||
|
Center,
|
||||||
|
Container,
|
||||||
|
Fieldset,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
PasswordInput,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useForm } from "@mantine/form";
|
||||||
|
import { IconFingerprint } from "@tabler/icons-react";
|
||||||
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
|
||||||
|
export default function Me() {
|
||||||
|
const client = useApi();
|
||||||
|
|
||||||
|
const user = useQuery({
|
||||||
|
queryKey: ["user"],
|
||||||
|
queryFn: async () => await client.get(`users/me`).then((res) => res.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatePassword = useMutation({
|
||||||
|
mutationFn: async (values: {
|
||||||
|
current_psk: string;
|
||||||
|
new_psk: string;
|
||||||
|
confirm_new_psk: string;
|
||||||
|
}) => {
|
||||||
|
await client.put(`/users/me/password`, {
|
||||||
|
current_password: values.current_psk,
|
||||||
|
new_password: values.new_psk,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const updatePskForm = useForm({
|
||||||
|
initialValues: {
|
||||||
|
current_psk: "",
|
||||||
|
new_psk: "",
|
||||||
|
confirm_new_psk: "",
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
current_psk: (value) =>
|
||||||
|
value.length === 0 ? "Please enter your current password" : null,
|
||||||
|
new_psk: (value) => {
|
||||||
|
if (value.length === 0) return "Please enter a new password";
|
||||||
|
if (value.length < 8 || value.length > 16)
|
||||||
|
return "Password must be between 8 and 16 characters";
|
||||||
|
},
|
||||||
|
confirm_new_psk: (value, values) => {
|
||||||
|
if (value.length === 0) return "Please confirm your new password";
|
||||||
|
if (value.length < 8 || value.length > 16)
|
||||||
|
return "Password must be between 8 and 16 characters";
|
||||||
|
if (value !== values.new_psk) return "Passwords must match";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
{user.isLoading ? (
|
||||||
|
<Center h="calc(100vh - 95px)">
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
) : user.isError ? (
|
||||||
|
<Center h="calc(100vh - 95px)">
|
||||||
|
<ErrorDisplay error="Error Loading User" />
|
||||||
|
</Center>
|
||||||
|
) : user.data ? (
|
||||||
|
<Stack pt="xl">
|
||||||
|
<Stack align="center" pb="xl">
|
||||||
|
<Avatar size="xl" />
|
||||||
|
<Title order={2}>{user.data.username}</Title>
|
||||||
|
<Text>
|
||||||
|
{user.data.level === 2
|
||||||
|
? "Admin"
|
||||||
|
: user.data.level === 1
|
||||||
|
? "User"
|
||||||
|
: "Guest"}
|
||||||
|
</Text>{" "}
|
||||||
|
</Stack>
|
||||||
|
<form
|
||||||
|
onSubmit={updatePskForm.onSubmit((values) => {
|
||||||
|
updatePassword.mutate(values);
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<Fieldset legend="Update Password">
|
||||||
|
<PasswordInput
|
||||||
|
label="Current Password"
|
||||||
|
{...updatePskForm.getInputProps("current_psk")}
|
||||||
|
/>
|
||||||
|
<PasswordInput
|
||||||
|
mt="sm"
|
||||||
|
label="New Password"
|
||||||
|
{...updatePskForm.getInputProps("new_psk")}
|
||||||
|
/>
|
||||||
|
<PasswordInput
|
||||||
|
mt="sm"
|
||||||
|
label="Confirm New Password"
|
||||||
|
{...updatePskForm.getInputProps("confirm_new_psk")}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end" mt="lg">
|
||||||
|
{updatePassword.isPending ? (
|
||||||
|
<Text>Updating...</Text>
|
||||||
|
) : updatePassword.isError ? (
|
||||||
|
updatePassword.error &&
|
||||||
|
(updatePassword.error as AxiosError).response?.status ===
|
||||||
|
403 ? (
|
||||||
|
<Text c="red">Incorrect password</Text>
|
||||||
|
) : (
|
||||||
|
<Text c="red">Failed: {updatePassword.error.message}</Text>
|
||||||
|
)
|
||||||
|
) : updatePassword.isSuccess ? (
|
||||||
|
<Text c="green">Updated</Text>
|
||||||
|
) : null}
|
||||||
|
<Button type="submit" leftSection={<IconFingerprint />}>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Fieldset>
|
||||||
|
</form>
|
||||||
|
</Stack>
|
||||||
|
) : (
|
||||||
|
<Text c="red">Unknown Error</Text>
|
||||||
|
)}
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
70
web/app/routes/logbook/route.tsx
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
import { TailfinAppShell } from "@/ui/nav/app-shell";
|
||||||
|
import { useAuth } from "@/util/auth";
|
||||||
|
import type { MetaFunction } from "@remix-run/node";
|
||||||
|
import { Outlet, useLocation, useNavigate } from "@remix-run/react";
|
||||||
|
import {
|
||||||
|
QueryCache,
|
||||||
|
QueryClient,
|
||||||
|
QueryClientProvider,
|
||||||
|
} from "@tanstack/react-query";
|
||||||
|
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export const meta: MetaFunction = () => {
|
||||||
|
return [
|
||||||
|
{ title: "Tailfin" },
|
||||||
|
{ name: "description", content: "Self-hosted flight logbook" },
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Index() {
|
||||||
|
const { user, loading } = useAuth();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!loading && !user) {
|
||||||
|
navigate("/login");
|
||||||
|
} else if (location.pathname === "/logbook") {
|
||||||
|
navigate("/logbook/dashboard");
|
||||||
|
}
|
||||||
|
}, [user, loading, navigate, location]);
|
||||||
|
|
||||||
|
const [queryClient] = useState(
|
||||||
|
() =>
|
||||||
|
new QueryClient({
|
||||||
|
queryCache: new QueryCache({
|
||||||
|
onError: (error: Error) => {
|
||||||
|
if (error instanceof AxiosError && error.response?.status === 401) {
|
||||||
|
navigate("/login");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
defaultOptions: {
|
||||||
|
queries: {
|
||||||
|
staleTime: 1000,
|
||||||
|
retry: (failureCount, error: Error) => {
|
||||||
|
return (
|
||||||
|
failureCount < 3 &&
|
||||||
|
(!error ||
|
||||||
|
(error instanceof AxiosError &&
|
||||||
|
error.response?.status !== 401 &&
|
||||||
|
error.response?.status !== 404))
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<TailfinAppShell>
|
||||||
|
<Outlet />
|
||||||
|
</TailfinAppShell>
|
||||||
|
<ReactQueryDevtools initialIsOpen={false} />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
107
web/app/routes/login.tsx
Normal file
@ -0,0 +1,107 @@
|
|||||||
|
import { useAuth } from "@/util/auth";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Center,
|
||||||
|
Container,
|
||||||
|
Fieldset,
|
||||||
|
Group,
|
||||||
|
Image,
|
||||||
|
PasswordInput,
|
||||||
|
Space,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Title,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useForm } from "@mantine/form";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
function LoginPage() {
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const form = useForm({
|
||||||
|
initialValues: {
|
||||||
|
username: "",
|
||||||
|
password: "",
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
username: (value) =>
|
||||||
|
value.length === 0 ? "Please enter a username" : null,
|
||||||
|
password: (value) =>
|
||||||
|
value.length === 0 ? "Please enter a password" : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const { signin } = useAuth();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = "Log In - Tailfin";
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container h="75%">
|
||||||
|
<Stack gap="md" h="100%" justify="center" align="stretch">
|
||||||
|
<Center>
|
||||||
|
<Image src="/logo.png" w="100px" />
|
||||||
|
</Center>
|
||||||
|
<Title order={2} style={{ textAlign: "center" }}>
|
||||||
|
Tailfin
|
||||||
|
</Title>
|
||||||
|
<Center>
|
||||||
|
<Fieldset legend="Log In" w="350px">
|
||||||
|
<form
|
||||||
|
onSubmit={form.onSubmit(async (values) => {
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
await signin(values)
|
||||||
|
.then(() => {})
|
||||||
|
.catch((err) => {
|
||||||
|
console.log(err);
|
||||||
|
setError((err as Error).message);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
setError((err as Error).message);
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
>
|
||||||
|
<TextInput
|
||||||
|
label="Username"
|
||||||
|
{...form.getInputProps("username")}
|
||||||
|
mt="md"
|
||||||
|
/>
|
||||||
|
<PasswordInput
|
||||||
|
label="Password"
|
||||||
|
{...form.getInputProps("password")}
|
||||||
|
mt="md"
|
||||||
|
/>
|
||||||
|
{error === "" ? (
|
||||||
|
<Space mt="md" />
|
||||||
|
) : (
|
||||||
|
<Text mt="md" c="red">
|
||||||
|
{error}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
<Group justify="center">
|
||||||
|
<Button type="submit" mt="xl" fullWidth>
|
||||||
|
Log In
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</form>
|
||||||
|
</Fieldset>
|
||||||
|
</Center>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<LoginPage />
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
35
web/app/ui/display/collapsible-fieldset.tsx
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { ActionIcon, Collapse, Fieldset, Group, Text } from "@mantine/core";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import { IconMinus, IconPlus } from "@tabler/icons-react";
|
||||||
|
import { ReactNode } from "react";
|
||||||
|
|
||||||
|
export default function CollapsibleFieldset({
|
||||||
|
children,
|
||||||
|
legend,
|
||||||
|
w = "",
|
||||||
|
mt = "",
|
||||||
|
}: {
|
||||||
|
children: ReactNode;
|
||||||
|
legend?: string;
|
||||||
|
w?: string;
|
||||||
|
mt?: string;
|
||||||
|
}) {
|
||||||
|
const [open, { toggle }] = useDisclosure(true);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Fieldset
|
||||||
|
legend={
|
||||||
|
<Group gap="xs">
|
||||||
|
{legend ? <Text>{legend}</Text> : null}
|
||||||
|
<ActionIcon variant="transparent" onClick={toggle} color="gray">
|
||||||
|
{open ? <IconMinus /> : <IconPlus />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
w={w}
|
||||||
|
mt={mt}
|
||||||
|
>
|
||||||
|
<Collapse in={open}>{children}</Collapse>
|
||||||
|
</Fieldset>
|
||||||
|
);
|
||||||
|
}
|
170
web/app/ui/display/editable/aircraft-log-item.tsx
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import AircraftForm from "@/ui/form/aircraft-form";
|
||||||
|
import { useApi } from "@/util/api";
|
||||||
|
import { useAircraft, usePatchFlight } from "@/util/hooks";
|
||||||
|
import { AircraftFormSchema, AircraftSchema } from "@/util/types";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Group,
|
||||||
|
Tooltip,
|
||||||
|
Text,
|
||||||
|
Select,
|
||||||
|
Modal,
|
||||||
|
Card,
|
||||||
|
Stack,
|
||||||
|
Button,
|
||||||
|
Loader,
|
||||||
|
UnstyledButton,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import { IconPlus, IconPencil, IconX } from "@tabler/icons-react";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export function AircraftLogItem({
|
||||||
|
label,
|
||||||
|
content,
|
||||||
|
id = "",
|
||||||
|
field = "",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
content: string | null;
|
||||||
|
id?: string;
|
||||||
|
field?: string;
|
||||||
|
}) {
|
||||||
|
const [editValue, setEditValue] = useState<string>(content ?? "");
|
||||||
|
const [editError, setEditError] = useState("");
|
||||||
|
|
||||||
|
const [editOpened, { open: openEdit, close: closeEdit }] =
|
||||||
|
useDisclosure(false);
|
||||||
|
|
||||||
|
const [aircraftOpened, { open: openAircraft, close: closeAircraft }] =
|
||||||
|
useDisclosure(false);
|
||||||
|
|
||||||
|
const client = useApi();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const addAircraft = useMutation({
|
||||||
|
mutationFn: async (values: AircraftFormSchema) => {
|
||||||
|
const newAircraft = values;
|
||||||
|
if (newAircraft) {
|
||||||
|
const res = await client.post("/aircraft", newAircraft);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
throw new Error("Aircraft creation failed");
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["aircraft-list"] });
|
||||||
|
close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const getAircraft = useAircraft();
|
||||||
|
|
||||||
|
const updateValue = usePatchFlight(id, field, closeEdit);
|
||||||
|
|
||||||
|
if (content === null) content = "";
|
||||||
|
const editForm = (
|
||||||
|
<Select
|
||||||
|
label={
|
||||||
|
<Group gap="0">
|
||||||
|
<Text size="sm" fw={700} span>
|
||||||
|
Aircraft
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Tooltip label="Add Aircraft">
|
||||||
|
<ActionIcon variant="transparent" onClick={openAircraft}>
|
||||||
|
<IconPlus size="1rem" />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
data={
|
||||||
|
getAircraft.isFetched
|
||||||
|
? getAircraft.data?.map((item: AircraftSchema) => ({
|
||||||
|
value: item.tail_no,
|
||||||
|
label: item.tail_no,
|
||||||
|
}))
|
||||||
|
: content
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
value: content,
|
||||||
|
label: content,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
allowDeselect={false}
|
||||||
|
value={editValue}
|
||||||
|
onChange={(_value, option) => {
|
||||||
|
setEditError("");
|
||||||
|
setEditValue(option.label);
|
||||||
|
}}
|
||||||
|
error={editError}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
opened={aircraftOpened}
|
||||||
|
onClose={closeAircraft}
|
||||||
|
title="New Aircraft"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<AircraftForm
|
||||||
|
onSubmit={addAircraft.mutate}
|
||||||
|
isError={addAircraft.isError}
|
||||||
|
error={addAircraft.error}
|
||||||
|
isPending={addAircraft.isPending}
|
||||||
|
submitButtonLabel="Add"
|
||||||
|
withCancelButton
|
||||||
|
cancelFunc={closeAircraft}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
<Modal
|
||||||
|
opened={editOpened}
|
||||||
|
onClose={closeEdit}
|
||||||
|
title={`Edit ${label}`}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
{editForm}
|
||||||
|
<Group justify="flex-end">
|
||||||
|
{updateValue.isPending ? <Loader /> : null}
|
||||||
|
{updateValue.isError ? (
|
||||||
|
<Text c="red">{updateValue.error?.message}</Text>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (editValue.length === 0) {
|
||||||
|
setEditError("Please select an aircraft");
|
||||||
|
} else {
|
||||||
|
updateValue.mutate(editValue);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
leftSection={<IconPencil />}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
<Card shadow="sm" withBorder h="100%">
|
||||||
|
<Stack gap="xs" align="center" h="100%">
|
||||||
|
<Text c="dimmed">{label}</Text>
|
||||||
|
<Tooltip label={`Edit ${label}`}>
|
||||||
|
<UnstyledButton onClick={openEdit}>
|
||||||
|
<Text
|
||||||
|
size="lg"
|
||||||
|
style={{ textAlign: "center" }}
|
||||||
|
c={content === "" ? "dimmed" : ""}
|
||||||
|
>
|
||||||
|
{content === "" ? <IconX /> : content}
|
||||||
|
</Text>
|
||||||
|
</UnstyledButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
105
web/app/ui/display/editable/date-log-item.tsx
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
import { usePatchFlight } from "@/util/hooks";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
UnstyledButton,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { DatePickerInput } from "@mantine/dates";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import { IconPencil, IconX } from "@tabler/icons-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import utc from "dayjs/plugin/utc.js";
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
|
|
||||||
|
export function DateLogItem({
|
||||||
|
label,
|
||||||
|
content,
|
||||||
|
id = "",
|
||||||
|
field = "",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
content: Date | string | null;
|
||||||
|
id?: string;
|
||||||
|
field?: string;
|
||||||
|
}) {
|
||||||
|
const [editValue, setEditValue] = useState<Date | null>(
|
||||||
|
content ? new Date(content as string) : null
|
||||||
|
);
|
||||||
|
const [editError, setEditError] = useState("");
|
||||||
|
|
||||||
|
const [editOpened, { open: openEdit, close: closeEdit }] =
|
||||||
|
useDisclosure(false);
|
||||||
|
|
||||||
|
const updateValue = usePatchFlight(id, field, closeEdit);
|
||||||
|
|
||||||
|
content = (content as string).split("T")[0];
|
||||||
|
const editForm = (
|
||||||
|
<DatePickerInput
|
||||||
|
label={label}
|
||||||
|
value={editValue}
|
||||||
|
onChange={setEditValue}
|
||||||
|
error={editError}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
opened={editOpened}
|
||||||
|
onClose={closeEdit}
|
||||||
|
title={`Edit ${label}`}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
{editForm}
|
||||||
|
<Group justify="flex-end">
|
||||||
|
{updateValue.isPending ? <Loader /> : null}
|
||||||
|
{updateValue.isError ? (
|
||||||
|
<Text c="red">{updateValue.error?.message}</Text>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (editValue === null) {
|
||||||
|
setEditError("Please select a date");
|
||||||
|
} else {
|
||||||
|
updateValue.mutate(
|
||||||
|
dayjs(editValue).utc().startOf("day").toISOString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
leftSection={<IconPencil />}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
<Card shadow="sm" withBorder h="100%">
|
||||||
|
<Stack gap="xs" align="center" h="100%">
|
||||||
|
<Text c="dimmed" style={{ textalign: "center" }}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Tooltip label={`Edit ${label}`}>
|
||||||
|
<UnstyledButton onClick={openEdit}>
|
||||||
|
<Text
|
||||||
|
size="lg"
|
||||||
|
style={{ textAlign: "center" }}
|
||||||
|
c={content === "" ? "dimmed" : ""}
|
||||||
|
>
|
||||||
|
{content === "" ? <IconX /> : content}
|
||||||
|
</Text>
|
||||||
|
</UnstyledButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
99
web/app/ui/display/editable/hour-log-item.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
UnstyledButton,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconPencil, IconX } from "@tabler/icons-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { ZeroHourInput } from "@/ui/input/hour-input";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import { usePatchFlight } from "@/util/hooks";
|
||||||
|
|
||||||
|
export function HourLogItem({
|
||||||
|
label,
|
||||||
|
content,
|
||||||
|
id = "",
|
||||||
|
field = "",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
content: number | string | null;
|
||||||
|
id?: string;
|
||||||
|
field?: string;
|
||||||
|
}) {
|
||||||
|
content = Number(content);
|
||||||
|
|
||||||
|
const [editValue, setEditValue] = useState<number | null>(content);
|
||||||
|
|
||||||
|
const [editError, setEditError] = useState("");
|
||||||
|
|
||||||
|
const [editOpened, { open: openEdit, close: closeEdit }] =
|
||||||
|
useDisclosure(false);
|
||||||
|
|
||||||
|
const updateValue = usePatchFlight(id, field, closeEdit);
|
||||||
|
|
||||||
|
const editForm = (
|
||||||
|
<ZeroHourInput
|
||||||
|
label=""
|
||||||
|
value={editValue}
|
||||||
|
setValue={setEditValue}
|
||||||
|
error={editError}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
opened={editOpened}
|
||||||
|
onClose={closeEdit}
|
||||||
|
title={`Edit ${label}`}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
{editForm}
|
||||||
|
<Group justify="flex-end">
|
||||||
|
{updateValue.isPending ? <Loader /> : null}
|
||||||
|
{updateValue.isError ? (
|
||||||
|
<Text c="red">{updateValue.error?.message}</Text>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (editValue === null || editValue < 0) {
|
||||||
|
setEditError("Please enter a valid hour number");
|
||||||
|
} else {
|
||||||
|
updateValue.mutate(editValue);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
leftSection={<IconPencil />}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
<Card shadow="sm" withBorder h="100%">
|
||||||
|
<Stack gap="xs" align="center" h="100%">
|
||||||
|
<Text c="dimmed" style={{ textalign: "center" }}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Tooltip label={`Edit ${label}`}>
|
||||||
|
<UnstyledButton onClick={openEdit}>
|
||||||
|
<Text
|
||||||
|
size="lg"
|
||||||
|
style={{ textAlign: "center" }}
|
||||||
|
c={content === null ? "dimmed" : ""}
|
||||||
|
>
|
||||||
|
{content === null ? <IconX /> : content}
|
||||||
|
</Text>
|
||||||
|
</UnstyledButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
28
web/app/ui/display/editable/img-log-item.module.css
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
.control {
|
||||||
|
&[data-inactive] {
|
||||||
|
opacity: 0;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.controls {
|
||||||
|
transition: opacity 150ms ease;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.root {
|
||||||
|
&:hover {
|
||||||
|
.controls {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator {
|
||||||
|
width: rem(12px);
|
||||||
|
height: rem(4px);
|
||||||
|
transition: width 250ms ease;
|
||||||
|
|
||||||
|
&[data-active] {
|
||||||
|
width: rem(40px);
|
||||||
|
}
|
||||||
|
}
|
150
web/app/ui/display/editable/img-log-item.tsx
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import { Carousel } from "@mantine/carousel";
|
||||||
|
import classes from "./img-log-item.module.css";
|
||||||
|
import { randomId, useDisclosure } from "@mantine/hooks";
|
||||||
|
import SecureImage from "../secure-img";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Button,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
Tooltip,
|
||||||
|
Text,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconPencil } from "@tabler/icons-react";
|
||||||
|
import ImageListInput from "@/ui/input/image-list-input";
|
||||||
|
import ImageUpload from "@/ui/input/image-upload";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useApi } from "@/util/api";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export default function ImageLogItem({
|
||||||
|
imageIds,
|
||||||
|
id,
|
||||||
|
mah = "",
|
||||||
|
}: {
|
||||||
|
imageIds: string[];
|
||||||
|
id: string;
|
||||||
|
mah?: string;
|
||||||
|
}) {
|
||||||
|
const [editOpened, { open: openEdit, close: closeEdit }] =
|
||||||
|
useDisclosure(false);
|
||||||
|
|
||||||
|
const [existingImages, setExistingImages] = useState<string[]>(imageIds);
|
||||||
|
const [newImages, setNewImages] = useState<File[]>([]);
|
||||||
|
|
||||||
|
const client = useApi();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const updateValue = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const missing = imageIds.filter(
|
||||||
|
(item: string) => existingImages?.indexOf(item) < 0
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const img of missing) {
|
||||||
|
await client.delete(`/img/${img}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await client
|
||||||
|
.patch(`/flights/${id}`, { images: existingImages })
|
||||||
|
.then((res) => res.data);
|
||||||
|
|
||||||
|
// Upload images
|
||||||
|
if (newImages.length > 0) {
|
||||||
|
const imageForm = new FormData();
|
||||||
|
|
||||||
|
for (const img of newImages ?? []) {
|
||||||
|
imageForm.append("images", img);
|
||||||
|
}
|
||||||
|
|
||||||
|
const img_id = await client.post(
|
||||||
|
`/flights/${id}/add_images`,
|
||||||
|
imageForm,
|
||||||
|
{ headers: { "Content-Type": "multipart/form-data" } }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!img_id) {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: [id] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["flights-list"] });
|
||||||
|
throw new Error("Image upload failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: [id] });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["flights-list"] });
|
||||||
|
closeEdit();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setExistingImages(imageIds);
|
||||||
|
}, [imageIds]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
opened={editOpened}
|
||||||
|
onClose={closeEdit}
|
||||||
|
title={`Edit Images`}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
<ImageUpload
|
||||||
|
value={newImages}
|
||||||
|
setValue={setNewImages}
|
||||||
|
label="Add Images"
|
||||||
|
mt="md"
|
||||||
|
placeholder="Images"
|
||||||
|
/>
|
||||||
|
<ImageListInput
|
||||||
|
label="Existing Images"
|
||||||
|
imageIds={existingImages}
|
||||||
|
setImageIds={setExistingImages}
|
||||||
|
collapsible
|
||||||
|
startCollapsed
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Group justify="flex-end">
|
||||||
|
{updateValue.isPending ? <Loader /> : null}
|
||||||
|
{updateValue.isError ? (
|
||||||
|
<Text c="red">{updateValue.error?.message}</Text>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
updateValue.mutate();
|
||||||
|
}}
|
||||||
|
leftSection={<IconPencil />}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
<Stack>
|
||||||
|
<Group justify="flex-end" py="0" my="0">
|
||||||
|
<Tooltip label="Edit Images">
|
||||||
|
<ActionIcon variant="transparent" onClick={openEdit}>
|
||||||
|
<IconPencil />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
<Carousel
|
||||||
|
style={{ maxHeight: mah }}
|
||||||
|
withIndicators
|
||||||
|
slideGap="sm"
|
||||||
|
slideSize={{ base: "100%", sm: "80%" }}
|
||||||
|
classNames={classes}
|
||||||
|
>
|
||||||
|
{imageIds.map((img) => (
|
||||||
|
<Carousel.Slide key={randomId()}>
|
||||||
|
<SecureImage key={randomId()} id={img} h="700px" radius="lg" />
|
||||||
|
</Carousel.Slide>
|
||||||
|
))}
|
||||||
|
</Carousel>
|
||||||
|
</Stack>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
99
web/app/ui/display/editable/int-log-item.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
UnstyledButton,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconPencil, IconX } from "@tabler/icons-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import { usePatchFlight } from "@/util/hooks";
|
||||||
|
import { ZeroIntInput } from "@/ui/input/int-input";
|
||||||
|
|
||||||
|
export function IntLogItem({
|
||||||
|
label,
|
||||||
|
content,
|
||||||
|
id = "",
|
||||||
|
field = "",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
content: number | string | null;
|
||||||
|
id?: string;
|
||||||
|
field?: string;
|
||||||
|
}) {
|
||||||
|
content = Number(content);
|
||||||
|
|
||||||
|
const [editValue, setEditValue] = useState<number>(content);
|
||||||
|
|
||||||
|
const [editError, setEditError] = useState("");
|
||||||
|
|
||||||
|
const [editOpened, { open: openEdit, close: closeEdit }] =
|
||||||
|
useDisclosure(false);
|
||||||
|
|
||||||
|
const updateValue = usePatchFlight(id, field, closeEdit);
|
||||||
|
|
||||||
|
const editForm = (
|
||||||
|
<ZeroIntInput
|
||||||
|
label=""
|
||||||
|
value={editValue}
|
||||||
|
setValue={setEditValue}
|
||||||
|
error={editError}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
opened={editOpened}
|
||||||
|
onClose={closeEdit}
|
||||||
|
title={`Edit ${label}`}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
{editForm}
|
||||||
|
<Group justify="flex-end">
|
||||||
|
{updateValue.isPending ? <Loader /> : null}
|
||||||
|
{updateValue.isError ? (
|
||||||
|
<Text c="red">{updateValue.error?.message}</Text>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (editValue === null || editValue < 0) {
|
||||||
|
setEditError("Please enter a valid number");
|
||||||
|
} else {
|
||||||
|
updateValue.mutate(editValue);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
leftSection={<IconPencil />}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
<Card shadow="sm" withBorder h="100%">
|
||||||
|
<Stack gap="xs" align="center" h="100%">
|
||||||
|
<Text c="dimmed" style={{ textalign: "center" }}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Tooltip label={`Edit ${label}`}>
|
||||||
|
<UnstyledButton onClick={openEdit}>
|
||||||
|
<Text
|
||||||
|
size="lg"
|
||||||
|
style={{ textAlign: "center" }}
|
||||||
|
c={content === null ? "dimmed" : ""}
|
||||||
|
>
|
||||||
|
{content === null ? <IconX /> : content}
|
||||||
|
</Text>
|
||||||
|
</UnstyledButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
115
web/app/ui/display/editable/list-log-item.tsx
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import {
|
||||||
|
Badge,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
UnstyledButton,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { randomId, useDisclosure } from "@mantine/hooks";
|
||||||
|
import { IconPencil, IconX } from "@tabler/icons-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import ListInput from "@/ui/input/list-input";
|
||||||
|
import { usePatchFlight } from "@/util/hooks";
|
||||||
|
|
||||||
|
export function LogItem({
|
||||||
|
label,
|
||||||
|
content,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
content: string | null;
|
||||||
|
}) {
|
||||||
|
if (content === null) content = "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" px="sm">
|
||||||
|
<Text>{label}</Text>
|
||||||
|
<Text>{content}</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ListLogItem({
|
||||||
|
label,
|
||||||
|
content,
|
||||||
|
listColor = "",
|
||||||
|
id = "",
|
||||||
|
field = "",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
content: string | string[] | null;
|
||||||
|
listColor?: string;
|
||||||
|
id?: string;
|
||||||
|
field?: string;
|
||||||
|
}) {
|
||||||
|
if (content === null) content = [];
|
||||||
|
if (content instanceof String) content = [content as string];
|
||||||
|
|
||||||
|
const [editValue, setEditValue] = useState<string[]>(content as string[]);
|
||||||
|
|
||||||
|
const [editOpened, { open: openEdit, close: closeEdit }] =
|
||||||
|
useDisclosure(false);
|
||||||
|
|
||||||
|
const updateValue = usePatchFlight(id, field, closeEdit);
|
||||||
|
|
||||||
|
const editForm = (
|
||||||
|
<ListInput label={label} value={editValue} setValue={setEditValue} />
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
opened={editOpened}
|
||||||
|
onClose={closeEdit}
|
||||||
|
title={`Edit ${label}`}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
{editForm}
|
||||||
|
<Group justify="flex-end">
|
||||||
|
{updateValue.isPending ? <Loader /> : null}
|
||||||
|
{updateValue.isError ? (
|
||||||
|
<Text c="red">{updateValue.error?.message}</Text>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
updateValue.mutate(editValue);
|
||||||
|
}}
|
||||||
|
leftSection={<IconPencil />}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
<Card shadow="sm" withBorder h="100%">
|
||||||
|
<Stack gap="xs" align="center" h="100%">
|
||||||
|
<Text c="dimmed" style={{ textalign: "center" }}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Tooltip label={`Edit ${label}`}>
|
||||||
|
<UnstyledButton onClick={openEdit}>
|
||||||
|
{(content as string[]).length > 0 ? (
|
||||||
|
<Text size="lg">
|
||||||
|
{(content as string[]).map((item) => (
|
||||||
|
<Badge key={randomId()} size="lg" mx="xs" color={listColor}>
|
||||||
|
{item}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text size="lg" style={{ textAlign: "center" }} c="dimmed">
|
||||||
|
<IconX />
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</UnstyledButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
88
web/app/ui/display/editable/text-log-item.tsx
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Textarea,
|
||||||
|
Tooltip,
|
||||||
|
UnstyledButton,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconPencil, IconX } from "@tabler/icons-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import { usePatchFlight } from "@/util/hooks";
|
||||||
|
|
||||||
|
export function TextLogItem({
|
||||||
|
label,
|
||||||
|
content,
|
||||||
|
id = "",
|
||||||
|
field = "",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
content: string | null;
|
||||||
|
id?: string;
|
||||||
|
field?: string;
|
||||||
|
}) {
|
||||||
|
const [editValue, setEditValue] = useState<string>(content ?? "");
|
||||||
|
|
||||||
|
const [editOpened, { open: openEdit, close: closeEdit }] =
|
||||||
|
useDisclosure(false);
|
||||||
|
|
||||||
|
const updateValue = usePatchFlight(id, field, closeEdit);
|
||||||
|
|
||||||
|
const editForm = (
|
||||||
|
<Textarea
|
||||||
|
label=""
|
||||||
|
value={editValue}
|
||||||
|
onChange={(event) => setEditValue(event.currentTarget.value)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
opened={editOpened}
|
||||||
|
onClose={closeEdit}
|
||||||
|
title={`Edit ${label}`}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
{editForm}
|
||||||
|
<Group justify="flex-end">
|
||||||
|
{updateValue.isPending ? <Loader /> : null}
|
||||||
|
{updateValue.isError ? (
|
||||||
|
<Text c="red">{updateValue.error?.message}</Text>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
onClick={() => updateValue.mutate(editValue)}
|
||||||
|
leftSection={<IconPencil />}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
<Card shadow="sm" withBorder h="100%">
|
||||||
|
<Stack gap="xs" align="center" h="100%">
|
||||||
|
<Text c="dimmed" style={{ textalign: "center" }}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Tooltip label={`Edit ${label}`}>
|
||||||
|
<UnstyledButton onClick={openEdit}>
|
||||||
|
<Text
|
||||||
|
size="lg"
|
||||||
|
style={{ textAlign: "center" }}
|
||||||
|
c={content === null ? "dimmed" : ""}
|
||||||
|
>
|
||||||
|
{content === null ? <IconX /> : content}
|
||||||
|
</Text>
|
||||||
|
</UnstyledButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
120
web/app/ui/display/editable/time-log-item.tsx
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
UnstyledButton,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconPencil, IconX } from "@tabler/icons-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import TimeInput from "@/ui/input/time-input";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { usePatchFlight } from "@/util/hooks";
|
||||||
|
import utc from "dayjs/plugin/utc.js";
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
|
|
||||||
|
export function TimeLogItem({
|
||||||
|
label,
|
||||||
|
content,
|
||||||
|
date,
|
||||||
|
id = "",
|
||||||
|
field = "",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
content: string | null;
|
||||||
|
date: dayjs.Dayjs | string;
|
||||||
|
id?: string;
|
||||||
|
field?: string;
|
||||||
|
}) {
|
||||||
|
if (date instanceof String) date = dayjs(date);
|
||||||
|
|
||||||
|
const time = (content as string).split("T")[1].split(":");
|
||||||
|
const [editValue, setEditValue] = useState<number | string | undefined>(
|
||||||
|
Number(`${time[0]}${time[1]}`)
|
||||||
|
);
|
||||||
|
|
||||||
|
const [editError, setEditError] = useState("");
|
||||||
|
|
||||||
|
const [editOpened, { open: openEdit, close: closeEdit }] =
|
||||||
|
useDisclosure(false);
|
||||||
|
|
||||||
|
const updateValue = usePatchFlight(id, field, closeEdit);
|
||||||
|
|
||||||
|
content = `${time[0]}:${time[1]}`;
|
||||||
|
const editForm = (
|
||||||
|
<TimeInput
|
||||||
|
label={label}
|
||||||
|
value={editValue}
|
||||||
|
setValue={setEditValue}
|
||||||
|
error={editError}
|
||||||
|
allowLeadingZeros
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
opened={editOpened}
|
||||||
|
onClose={closeEdit}
|
||||||
|
title={`Edit ${label}`}
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<Stack>
|
||||||
|
{editForm}
|
||||||
|
<Group justify="flex-end">
|
||||||
|
{updateValue.isPending ? <Loader /> : null}
|
||||||
|
{updateValue.isError ? (
|
||||||
|
<Text c="red">{updateValue.error?.message}</Text>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
onClick={() => {
|
||||||
|
if (Number(editValue) > 2359)
|
||||||
|
setEditError("Time must be between 0000 and 2359");
|
||||||
|
else if (Number(editValue) % 100 > 59)
|
||||||
|
setEditError("Minutes must not exceed 59");
|
||||||
|
else {
|
||||||
|
updateValue.mutate(
|
||||||
|
dayjs(date)
|
||||||
|
.utc()
|
||||||
|
.hour(Math.floor((Number(editValue) ?? 0) / 100))
|
||||||
|
.minute(Math.floor((Number(editValue) ?? 0) % 100))
|
||||||
|
.second(0)
|
||||||
|
.millisecond(0)
|
||||||
|
.toISOString()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
leftSection={<IconPencil />}
|
||||||
|
>
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Modal>
|
||||||
|
<Card shadow="sm" withBorder h="100%">
|
||||||
|
<Stack gap="xs" align="center" h="100%">
|
||||||
|
<Text c="dimmed" style={{ textalign: "center" }}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Tooltip label={`Edit ${label}`}>
|
||||||
|
<UnstyledButton onClick={openEdit}>
|
||||||
|
<Text
|
||||||
|
size="lg"
|
||||||
|
style={{ textAlign: "center" }}
|
||||||
|
c={content === null ? "dimmed" : ""}
|
||||||
|
>
|
||||||
|
{content === null ? <IconX /> : content}
|
||||||
|
</Text>
|
||||||
|
</UnstyledButton>
|
||||||
|
</Tooltip>
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
84
web/app/ui/display/log-item.tsx
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import { Badge, Card, Group, Stack, Text } from "@mantine/core";
|
||||||
|
import { randomId } from "@mantine/hooks";
|
||||||
|
import { IconX } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
export function LogItem({
|
||||||
|
label,
|
||||||
|
content,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
content: string | null;
|
||||||
|
}) {
|
||||||
|
if (content === null) content = "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Group justify="space-between" px="sm">
|
||||||
|
<Text>{label}</Text>
|
||||||
|
<Text>{content}</Text>
|
||||||
|
</Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function VerticalLogItem({
|
||||||
|
label,
|
||||||
|
content,
|
||||||
|
decimal = 0,
|
||||||
|
hours = false,
|
||||||
|
time = false,
|
||||||
|
date = false,
|
||||||
|
list = false,
|
||||||
|
listColor = "",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
content: string | string[] | null;
|
||||||
|
decimal?: number;
|
||||||
|
hours?: boolean;
|
||||||
|
time?: boolean;
|
||||||
|
date?: boolean;
|
||||||
|
list?: boolean;
|
||||||
|
listColor?: string;
|
||||||
|
}) {
|
||||||
|
if (content === null) content = "";
|
||||||
|
if (decimal > 0) content = Number(content).toFixed(decimal);
|
||||||
|
if (hours) content = Number(content).toFixed(1);
|
||||||
|
if (time) {
|
||||||
|
const time = (content as string).split("T")[1].split(":");
|
||||||
|
content = `${time[0]}:${time[1]}`;
|
||||||
|
}
|
||||||
|
if (date) content = (content as string).split("T")[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card shadow="sm" withBorder h="100%">
|
||||||
|
<Stack gap="xs" align="center" h="100%">
|
||||||
|
<Text c="dimmed" style={{ textalign: "center" }}>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
{list ? (
|
||||||
|
<>
|
||||||
|
{(content as string[]).length > 0 ? (
|
||||||
|
<Text size="lg">
|
||||||
|
{(content as string[]).map((item) => (
|
||||||
|
<Badge key={randomId()} size="lg" mx="xs" color={listColor}>
|
||||||
|
{item}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<Text size="lg" style={{ textAlign: "center" }} c="dimmed">
|
||||||
|
<IconX />
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Text
|
||||||
|
size="lg"
|
||||||
|
style={{ textAlign: "center" }}
|
||||||
|
c={content === "" ? "dimmed" : ""}
|
||||||
|
>
|
||||||
|
{content === "" ? <IconX /> : content}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</Stack>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
99
web/app/ui/display/secure-img.tsx
Normal file
@ -0,0 +1,99 @@
|
|||||||
|
import { useApi } from "@/util/api";
|
||||||
|
import { Center, Image, Loader, Modal } from "@mantine/core";
|
||||||
|
import { UseQueryResult, useQuery } from "@tanstack/react-query";
|
||||||
|
import ErrorDisplay from "../error-display";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
|
||||||
|
function blobToBase64(blob: Blob): Promise<string | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.readAsBinaryString(blob);
|
||||||
|
reader.onloadend = function () {
|
||||||
|
if (typeof reader.result === "string") {
|
||||||
|
const base64 = btoa(reader.result);
|
||||||
|
resolve(base64);
|
||||||
|
} else {
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function useFetchImageAsBase64(
|
||||||
|
img_id: string
|
||||||
|
): UseQueryResult<{ blob: string | null; type: string }> {
|
||||||
|
const client = useApi();
|
||||||
|
|
||||||
|
return useQuery({
|
||||||
|
queryKey: ["image", img_id],
|
||||||
|
queryFn: async (): Promise<{
|
||||||
|
blob: string;
|
||||||
|
type: string;
|
||||||
|
}> => {
|
||||||
|
const response = await client.get(`/img/${img_id}`, {
|
||||||
|
responseType: "arraybuffer",
|
||||||
|
});
|
||||||
|
|
||||||
|
const blob = (await blobToBase64(new Blob([response.data]))) as string;
|
||||||
|
const type = (response.headers["content-type"] as string) ?? "image/jpeg";
|
||||||
|
|
||||||
|
return { blob, type };
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SecureImage({
|
||||||
|
id,
|
||||||
|
radius = "sm",
|
||||||
|
h = "",
|
||||||
|
clickable = true,
|
||||||
|
}: {
|
||||||
|
id: string;
|
||||||
|
radius?: string;
|
||||||
|
h?: string;
|
||||||
|
clickable?: boolean;
|
||||||
|
}) {
|
||||||
|
const { isLoading, error, data } = useFetchImageAsBase64(id);
|
||||||
|
|
||||||
|
const [opened, { open, close }] = useDisclosure(false);
|
||||||
|
|
||||||
|
if (isLoading)
|
||||||
|
return (
|
||||||
|
<Center h="500px">
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (error) return <ErrorDisplay error="Failed to load image" />;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{clickable ? (
|
||||||
|
<Modal
|
||||||
|
title="Image"
|
||||||
|
opened={opened}
|
||||||
|
onClose={close}
|
||||||
|
centered
|
||||||
|
size="auto"
|
||||||
|
>
|
||||||
|
<Image src={`data:${data?.type};base64,${data?.blob}`} />
|
||||||
|
</Modal>
|
||||||
|
) : null}
|
||||||
|
<Image
|
||||||
|
src={`data:${data?.type};base64,${data?.blob}`}
|
||||||
|
radius={radius}
|
||||||
|
w="auto"
|
||||||
|
maw="100%"
|
||||||
|
h="100%"
|
||||||
|
m="auto"
|
||||||
|
fit="contain"
|
||||||
|
style={{ maxHeight: h ?? "" }}
|
||||||
|
onClick={() => {
|
||||||
|
if (clickable) {
|
||||||
|
open();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
13
web/app/ui/error-display.tsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { Stack, Text } from "@mantine/core";
|
||||||
|
import { IconAlertTriangle } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
export default function ErrorDisplay({ error }: { error: string }) {
|
||||||
|
return (
|
||||||
|
<Stack align="center" justify="center" h="100%" m="0" p="0">
|
||||||
|
<Text c="red">
|
||||||
|
<IconAlertTriangle size="3rem" />
|
||||||
|
</Text>
|
||||||
|
<Text c="red">{error}</Text>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
186
web/app/ui/form/aircraft-form.tsx
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
import { useApi } from "@/util/api";
|
||||||
|
import { AircraftFormSchema } from "@/util/types";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Group,
|
||||||
|
NumberInput,
|
||||||
|
Select,
|
||||||
|
Stack,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useForm } from "@mantine/form";
|
||||||
|
import { IconPencil, IconX } from "@tabler/icons-react";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export default function AircraftForm({
|
||||||
|
onSubmit,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
isPending,
|
||||||
|
initialValues,
|
||||||
|
submitButtonLabel,
|
||||||
|
withCancelButton,
|
||||||
|
cancelFunc,
|
||||||
|
}: {
|
||||||
|
onSubmit: (values: AircraftFormSchema) => void;
|
||||||
|
isError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
isPending: boolean;
|
||||||
|
initialValues?: AircraftFormSchema | null;
|
||||||
|
mah?: string;
|
||||||
|
submitButtonLabel?: string;
|
||||||
|
withCancelButton?: boolean;
|
||||||
|
cancelFunc?: () => void;
|
||||||
|
}) {
|
||||||
|
const newForm = useForm<AircraftFormSchema>({
|
||||||
|
initialValues: initialValues ?? {
|
||||||
|
tail_no: "",
|
||||||
|
make: "",
|
||||||
|
model: "",
|
||||||
|
aircraft_category: "",
|
||||||
|
aircraft_class: "",
|
||||||
|
hobbs: 0.0,
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
tail_no: (value) =>
|
||||||
|
value === null || value.trim() === ""
|
||||||
|
? "Please enter a tail number"
|
||||||
|
: null,
|
||||||
|
make: (value) =>
|
||||||
|
value === null || value.trim() === "" ? "Please enter a make" : null,
|
||||||
|
model: (value) =>
|
||||||
|
value === null || value.trim() === "" ? "Please enter a model" : null,
|
||||||
|
aircraft_category: (value) =>
|
||||||
|
value === null || value.trim() === ""
|
||||||
|
? "Please select a category"
|
||||||
|
: null,
|
||||||
|
aircraft_class: (value) =>
|
||||||
|
value === null || value.trim() === "" ? "Please select a class" : null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const client = useApi();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const categories = useQuery({
|
||||||
|
queryKey: ["categories"],
|
||||||
|
queryFn: async () =>
|
||||||
|
await client.get(`/aircraft/categories`).then((res) => res.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
const [category, setCategory] = useState(
|
||||||
|
initialValues?.aircraft_category ?? ""
|
||||||
|
);
|
||||||
|
const [classSelection, setClassSelection] = useState<string | null>(
|
||||||
|
initialValues?.aircraft_class ?? ""
|
||||||
|
);
|
||||||
|
|
||||||
|
const classes = useQuery({
|
||||||
|
queryKey: ["classes", category],
|
||||||
|
queryFn: async () =>
|
||||||
|
await client
|
||||||
|
.get(`/aircraft/class?category=${category}`)
|
||||||
|
.then((res) => res.data),
|
||||||
|
enabled: !!category,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={newForm.onSubmit((values) => onSubmit(values))}>
|
||||||
|
<Container>
|
||||||
|
<Stack>
|
||||||
|
<TextInput
|
||||||
|
label="Tail Number"
|
||||||
|
withAsterisk
|
||||||
|
{...newForm.getInputProps("tail_no")}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Make"
|
||||||
|
{...newForm.getInputProps("make")}
|
||||||
|
withAsterisk
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Model"
|
||||||
|
{...newForm.getInputProps("model")}
|
||||||
|
withAsterisk
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
{...newForm.getInputProps("aircraft_category")}
|
||||||
|
label="Category"
|
||||||
|
placeholder="Pick a value"
|
||||||
|
withAsterisk
|
||||||
|
data={
|
||||||
|
categories.isFetched && !categories.isError
|
||||||
|
? categories.data.categories
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
onChange={(_value, option) => {
|
||||||
|
newForm.setFieldValue("aircraft_category", option.value);
|
||||||
|
setCategory(option.value);
|
||||||
|
newForm.setFieldValue("aircraft_class", "");
|
||||||
|
setClassSelection(null);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["classes", option.value],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
key={classSelection}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
{...newForm.getInputProps("aircraft_class")}
|
||||||
|
label="Class"
|
||||||
|
placeholder="Pick a value"
|
||||||
|
withAsterisk
|
||||||
|
data={
|
||||||
|
classes.isFetched && !classes.isError && classes.data
|
||||||
|
? classes.data.classes
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
value={classSelection}
|
||||||
|
onChange={(_value, option) => {
|
||||||
|
newForm.setFieldValue("aircraft_class", option.label);
|
||||||
|
setClassSelection(option.label);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Hobbs"
|
||||||
|
min={0.0}
|
||||||
|
suffix=" hrs"
|
||||||
|
decimalScale={1}
|
||||||
|
fixedDecimalScale
|
||||||
|
{...newForm.getInputProps("hobbs")}
|
||||||
|
/>
|
||||||
|
<Group justify="flex-end">
|
||||||
|
{isError ? (
|
||||||
|
<Text c="red">
|
||||||
|
{error instanceof AxiosError
|
||||||
|
? error?.response?.data?.detail ??
|
||||||
|
error?.response?.data ??
|
||||||
|
error?.response ??
|
||||||
|
error?.message ??
|
||||||
|
"Failed to submit"
|
||||||
|
: error?.message}
|
||||||
|
</Text>
|
||||||
|
) : isPending ? (
|
||||||
|
<Text c="yellow">Loading...</Text>
|
||||||
|
) : null}
|
||||||
|
{withCancelButton ? (
|
||||||
|
<Button leftSection={<IconX />} color="gray" onClick={cancelFunc}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
leftSection={<IconPencil />}
|
||||||
|
onClick={() => null}
|
||||||
|
>
|
||||||
|
{submitButtonLabel ?? "Submit"}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</Stack>
|
||||||
|
</Container>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
556
web/app/ui/form/flight-form.tsx
Normal file
@ -0,0 +1,556 @@
|
|||||||
|
import {
|
||||||
|
AircraftFormSchema,
|
||||||
|
AircraftSchema,
|
||||||
|
FlightFormSchema,
|
||||||
|
} from "@/util/types";
|
||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Button,
|
||||||
|
CloseButton,
|
||||||
|
Container,
|
||||||
|
Fieldset,
|
||||||
|
Group,
|
||||||
|
Loader,
|
||||||
|
Modal,
|
||||||
|
NumberInput,
|
||||||
|
ScrollArea,
|
||||||
|
Select,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
Textarea,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { DatePickerInput } from "@mantine/dates";
|
||||||
|
import { useForm } from "@mantine/form";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { HourInput, ZeroHourInput } from "./hour-input";
|
||||||
|
import TimeInput from "./time-input";
|
||||||
|
import { ZeroIntInput } from "./int-input";
|
||||||
|
import ListInput from "./list-input";
|
||||||
|
import { IconPencil, IconPlaneTilt, IconPlus } from "@tabler/icons-react";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import AircraftForm from "./aircraft-form";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useApi } from "@/util/api";
|
||||||
|
import { useAircraft } from "@/util/hooks";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import ImageUpload from "./image-upload";
|
||||||
|
import ImageListInput from "./image-list-input";
|
||||||
|
|
||||||
|
export default function FlightForm({
|
||||||
|
onSubmit,
|
||||||
|
isPending,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
initialValues,
|
||||||
|
mah,
|
||||||
|
submitButtonLabel,
|
||||||
|
withCancelButton,
|
||||||
|
cancelFunc,
|
||||||
|
autofillHobbs = false,
|
||||||
|
}: {
|
||||||
|
onSubmit: (values: FlightFormSchema) => void;
|
||||||
|
isPending: boolean;
|
||||||
|
isError: boolean;
|
||||||
|
error: Error | null;
|
||||||
|
initialValues?: FlightFormSchema | null;
|
||||||
|
mah?: string;
|
||||||
|
submitButtonLabel?: string;
|
||||||
|
withCancelButton?: boolean;
|
||||||
|
cancelFunc?: () => void;
|
||||||
|
autofillHobbs?: boolean;
|
||||||
|
}) {
|
||||||
|
const validate_time = (value: number | null) => {
|
||||||
|
if (value === null) return;
|
||||||
|
if (value > 2359) return "Time must be between 0000 and 2359";
|
||||||
|
if (value % 100 > 59) return "Minutes must not exceed 59";
|
||||||
|
};
|
||||||
|
|
||||||
|
const form = useForm<FlightFormSchema>({
|
||||||
|
initialValues: initialValues ?? {
|
||||||
|
date: dayjs(),
|
||||||
|
aircraft: "",
|
||||||
|
waypoint_from: "",
|
||||||
|
waypoint_to: "",
|
||||||
|
route: "",
|
||||||
|
|
||||||
|
hobbs_start: null,
|
||||||
|
hobbs_end: null,
|
||||||
|
|
||||||
|
time_start: null,
|
||||||
|
time_off: null,
|
||||||
|
time_down: null,
|
||||||
|
time_stop: null,
|
||||||
|
|
||||||
|
time_total: 0.0,
|
||||||
|
time_pic: 0.0,
|
||||||
|
time_sic: 0.0,
|
||||||
|
time_night: 0.0,
|
||||||
|
time_solo: 0.0,
|
||||||
|
|
||||||
|
time_xc: 0.0,
|
||||||
|
dist_xc: 0.0,
|
||||||
|
|
||||||
|
landings_day: 0,
|
||||||
|
landings_night: 0,
|
||||||
|
|
||||||
|
time_instrument: 0.0,
|
||||||
|
time_sim_instrument: 0.0,
|
||||||
|
holds_instrument: 0,
|
||||||
|
|
||||||
|
dual_given: 0.0,
|
||||||
|
dual_recvd: 0.0,
|
||||||
|
time_sim: 0.0,
|
||||||
|
time_ground: 0.0,
|
||||||
|
|
||||||
|
tags: [],
|
||||||
|
|
||||||
|
pax: [],
|
||||||
|
crew: [],
|
||||||
|
|
||||||
|
comments: "",
|
||||||
|
|
||||||
|
existing_images: [],
|
||||||
|
images: [],
|
||||||
|
},
|
||||||
|
validate: {
|
||||||
|
aircraft: (value) =>
|
||||||
|
value?.length ?? 0 > 0 ? null : "Please select an aircraft",
|
||||||
|
time_start: (value) => validate_time(value),
|
||||||
|
time_off: (value) => validate_time(value),
|
||||||
|
time_down: (value) => validate_time(value),
|
||||||
|
time_stop: (value) => validate_time(value),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [aircraftOpened, { open: openAircraft, close: closeAircraft }] =
|
||||||
|
useDisclosure(false);
|
||||||
|
|
||||||
|
const client = useApi();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const addAircraft = useMutation({
|
||||||
|
mutationFn: async (values: AircraftFormSchema) => {
|
||||||
|
const newAircraft = values;
|
||||||
|
if (newAircraft) {
|
||||||
|
const res = await client.post("/aircraft", newAircraft);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
throw new Error("Aircraft creation failed");
|
||||||
|
},
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: ["aircraft-list"] });
|
||||||
|
close();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [aircraft, setAircraft] = useState<string | null>(
|
||||||
|
initialValues?.aircraft ?? ""
|
||||||
|
);
|
||||||
|
|
||||||
|
const [hobbsTouched, setHobbsTouched] = useState(false);
|
||||||
|
|
||||||
|
const getHobbs = useQuery({
|
||||||
|
queryKey: ["hobbs", aircraft],
|
||||||
|
queryFn: async () =>
|
||||||
|
await client.get(`/aircraft/tail/${aircraft}`).then((res) => res.data),
|
||||||
|
enabled: !!aircraft && aircraft !== "",
|
||||||
|
});
|
||||||
|
|
||||||
|
const getAircraft = useAircraft();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autofillHobbs && getHobbs.isFetched && getHobbs.data && !hobbsTouched) {
|
||||||
|
form.setFieldValue(
|
||||||
|
"hobbs_start",
|
||||||
|
getHobbs.data.hobbs ?? form.getTransformedValues()["hobbs_start"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [getHobbs.data]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Modal
|
||||||
|
opened={aircraftOpened}
|
||||||
|
onClose={closeAircraft}
|
||||||
|
title="New Aircraft"
|
||||||
|
centered
|
||||||
|
>
|
||||||
|
<AircraftForm
|
||||||
|
onSubmit={addAircraft.mutate}
|
||||||
|
isError={addAircraft.isError}
|
||||||
|
error={addAircraft.error}
|
||||||
|
isPending={addAircraft.isPending}
|
||||||
|
submitButtonLabel="Add"
|
||||||
|
withCancelButton
|
||||||
|
cancelFunc={closeAircraft}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
<form onSubmit={form.onSubmit((values) => onSubmit(values))}>
|
||||||
|
<ScrollArea.Autosize mah={mah}>
|
||||||
|
<Container>
|
||||||
|
{/* Date and Aircraft */}
|
||||||
|
|
||||||
|
<Fieldset>
|
||||||
|
<Group justify="center" grow>
|
||||||
|
<DatePickerInput
|
||||||
|
label="Date"
|
||||||
|
{...form.getInputProps("date")}
|
||||||
|
withAsterisk
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
label={
|
||||||
|
<Group gap="0">
|
||||||
|
<Text size="sm" fw={700} span>
|
||||||
|
Aircraft
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
pl="0.3rem"
|
||||||
|
style={{ color: "var(--mantine-color-error)" }}
|
||||||
|
span
|
||||||
|
>
|
||||||
|
*
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Tooltip label="Add Aircraft">
|
||||||
|
<ActionIcon
|
||||||
|
variant="transparent"
|
||||||
|
onClick={openAircraft}
|
||||||
|
>
|
||||||
|
<IconPlus size="1rem" />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
data={
|
||||||
|
getAircraft.isFetched
|
||||||
|
? getAircraft.data?.map((item: AircraftSchema) => ({
|
||||||
|
value: item.tail_no,
|
||||||
|
label: item.tail_no,
|
||||||
|
}))
|
||||||
|
: initialValues
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
value: initialValues?.aircraft,
|
||||||
|
label: initialValues?.aircraft,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
allowDeselect={false}
|
||||||
|
value={aircraft}
|
||||||
|
{...form.getInputProps("aircraft")}
|
||||||
|
onChange={(_value, option) => {
|
||||||
|
form.setFieldValue("aircraft", option.label);
|
||||||
|
setAircraft(option.label);
|
||||||
|
queryClient.invalidateQueries({
|
||||||
|
queryKey: ["hobbs", aircraft],
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Fieldset>
|
||||||
|
|
||||||
|
{/* Route */}
|
||||||
|
|
||||||
|
<Fieldset legend="Route" mt="lg">
|
||||||
|
<Group justify="center" grow>
|
||||||
|
<TextInput
|
||||||
|
label="Waypoint From"
|
||||||
|
{...form.getInputProps("waypoint_from")}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Waypoint To"
|
||||||
|
{...form.getInputProps("waypoint_to")}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<TextInput
|
||||||
|
label="Route"
|
||||||
|
{...form.getInputProps("route")}
|
||||||
|
mt="md"
|
||||||
|
/>
|
||||||
|
</Fieldset>
|
||||||
|
|
||||||
|
{/* Times */}
|
||||||
|
|
||||||
|
<Fieldset legend="Times" mt="md">
|
||||||
|
<Group justify="center" grow>
|
||||||
|
<NumberInput
|
||||||
|
label={
|
||||||
|
<Group gap="0">
|
||||||
|
<Text size="sm" fw={700} span>
|
||||||
|
Hobbs Start
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
label={
|
||||||
|
getHobbs.isFetched &&
|
||||||
|
getHobbs.data &&
|
||||||
|
getHobbs.data.hobbs ===
|
||||||
|
form.getTransformedValues()["hobbs_start"]
|
||||||
|
? "Using aircraft time"
|
||||||
|
: "Use Aircraft Time"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ActionIcon
|
||||||
|
variant="transparent"
|
||||||
|
disabled={
|
||||||
|
!(
|
||||||
|
getHobbs.isFetched &&
|
||||||
|
getHobbs.data &&
|
||||||
|
getHobbs.data.hobbs !==
|
||||||
|
form.getTransformedValues()["hobbs_start"]
|
||||||
|
)
|
||||||
|
}
|
||||||
|
style={
|
||||||
|
!(
|
||||||
|
getHobbs.isFetched &&
|
||||||
|
getHobbs.data &&
|
||||||
|
getHobbs.data.hobbs !==
|
||||||
|
form.getTransformedValues()["hobbs_start"]
|
||||||
|
)
|
||||||
|
? { backgroundColor: "transparent" }
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
onClick={() =>
|
||||||
|
form.setFieldValue(
|
||||||
|
"hobbs_start",
|
||||||
|
getHobbs.data?.hobbs ?? 0.0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<IconPlaneTilt size="1rem" />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
</Group>
|
||||||
|
}
|
||||||
|
decimalScale={1}
|
||||||
|
step={0.1}
|
||||||
|
min={0}
|
||||||
|
fixedDecimalScale
|
||||||
|
leftSection={
|
||||||
|
<CloseButton
|
||||||
|
aria-label="Clear input"
|
||||||
|
onClick={() => form.setFieldValue("hobbs_start", "")}
|
||||||
|
style={{
|
||||||
|
display:
|
||||||
|
["", null].indexOf(
|
||||||
|
form.getTransformedValues()["hobbs_start"] as
|
||||||
|
| string
|
||||||
|
| null
|
||||||
|
) > -1
|
||||||
|
? "none"
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{...form.getInputProps("hobbs_start")}
|
||||||
|
onChange={(e) => {
|
||||||
|
form.setFieldValue("hobbs_start", e);
|
||||||
|
setHobbsTouched(true);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<HourInput form={form} field="hobbs_end" label="Hobbs End" />
|
||||||
|
</Group>
|
||||||
|
</Fieldset>
|
||||||
|
|
||||||
|
{/* Start/Stop */}
|
||||||
|
|
||||||
|
<Fieldset legend="Start/Stop" mt="md">
|
||||||
|
<Group justify="center" grow>
|
||||||
|
<TimeInput
|
||||||
|
form={form}
|
||||||
|
field="time_start"
|
||||||
|
label="Start Time"
|
||||||
|
allowLeadingZeros
|
||||||
|
/>
|
||||||
|
<TimeInput
|
||||||
|
form={form}
|
||||||
|
field="time_off"
|
||||||
|
label="Time Off"
|
||||||
|
allowLeadingZeros
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<Group justify="center" grow mt="md">
|
||||||
|
<TimeInput
|
||||||
|
form={form}
|
||||||
|
field="time_down"
|
||||||
|
label="Time Down"
|
||||||
|
allowLeadingZeros
|
||||||
|
/>
|
||||||
|
<TimeInput
|
||||||
|
form={form}
|
||||||
|
field="time_stop"
|
||||||
|
label="Stop Time"
|
||||||
|
allowLeadingZeros
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Fieldset>
|
||||||
|
|
||||||
|
{/* Hours */}
|
||||||
|
|
||||||
|
<Fieldset legend="Hours" mt="md">
|
||||||
|
<Group justify="center" grow>
|
||||||
|
<ZeroHourInput
|
||||||
|
form={form}
|
||||||
|
field="time_total"
|
||||||
|
label="Time Total"
|
||||||
|
/>
|
||||||
|
<ZeroHourInput form={form} field="time_pic" label="Time PIC" />
|
||||||
|
<ZeroHourInput form={form} field="time_sic" label="Time SIC" />
|
||||||
|
</Group>
|
||||||
|
<Group justify="center" grow mt="md">
|
||||||
|
<ZeroHourInput
|
||||||
|
form={form}
|
||||||
|
field="time_night"
|
||||||
|
label="Time Night"
|
||||||
|
/>
|
||||||
|
<ZeroHourInput
|
||||||
|
form={form}
|
||||||
|
field="time_solo"
|
||||||
|
label="Time Solo"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Fieldset>
|
||||||
|
|
||||||
|
{/* Cross-Country */}
|
||||||
|
|
||||||
|
<Fieldset legend="Cross-Country" mt="md">
|
||||||
|
<Group justify="center" grow>
|
||||||
|
<ZeroHourInput form={form} field="time_xc" label="Hours" />
|
||||||
|
<NumberInput
|
||||||
|
label="Distance"
|
||||||
|
decimalScale={1}
|
||||||
|
min={0}
|
||||||
|
fixedDecimalScale
|
||||||
|
leftSection={
|
||||||
|
<CloseButton
|
||||||
|
aria-label="Clear input"
|
||||||
|
onClick={() => form.setFieldValue("dist_xc", 0)}
|
||||||
|
style={{
|
||||||
|
display:
|
||||||
|
form.getTransformedValues().dist_xc == 0
|
||||||
|
? "none"
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{...form.getInputProps("dist_xc")}
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Fieldset>
|
||||||
|
|
||||||
|
{/* Landings */}
|
||||||
|
|
||||||
|
<Fieldset legend="Landings" mt="md">
|
||||||
|
<Group justify="center" grow>
|
||||||
|
<ZeroIntInput form={form} field="landings_day" label="Day" />
|
||||||
|
<ZeroIntInput
|
||||||
|
form={form}
|
||||||
|
field="landings_night"
|
||||||
|
label="Night"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Fieldset>
|
||||||
|
|
||||||
|
{/* Instrument */}
|
||||||
|
|
||||||
|
<Fieldset legend="Instrument" mt="md">
|
||||||
|
<Group justify="center" grow>
|
||||||
|
<ZeroHourInput
|
||||||
|
form={form}
|
||||||
|
field="time_instrument"
|
||||||
|
label="Time Instrument"
|
||||||
|
/>
|
||||||
|
<ZeroHourInput
|
||||||
|
form={form}
|
||||||
|
field="time_sim_instrument"
|
||||||
|
label="Time Sim Instrument"
|
||||||
|
/>
|
||||||
|
<ZeroIntInput
|
||||||
|
form={form}
|
||||||
|
field="holds_instrument"
|
||||||
|
label="Instrument Holds"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Fieldset>
|
||||||
|
|
||||||
|
{/* Instruction */}
|
||||||
|
|
||||||
|
<Fieldset legend="Instruction" mt="md">
|
||||||
|
<Group justify="center" grow>
|
||||||
|
<ZeroHourInput
|
||||||
|
form={form}
|
||||||
|
field="dual_given"
|
||||||
|
label="Dual Given"
|
||||||
|
/>
|
||||||
|
<ZeroHourInput
|
||||||
|
form={form}
|
||||||
|
field="dual_recvd"
|
||||||
|
label="Dual Received"
|
||||||
|
/>
|
||||||
|
<ZeroHourInput form={form} field="time_sim" label="Sim Time" />
|
||||||
|
<ZeroHourInput
|
||||||
|
form={form}
|
||||||
|
field="time_ground"
|
||||||
|
label="Ground Time"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
</Fieldset>
|
||||||
|
|
||||||
|
{/* About the Flight */}
|
||||||
|
|
||||||
|
<Fieldset legend="About" mt="md">
|
||||||
|
<ListInput form={form} field="tags" label="Tags" />
|
||||||
|
<Group justify="center" grow mt="md">
|
||||||
|
<ListInput form={form} field="pax" label="Pax" />
|
||||||
|
<ListInput form={form} field="crew" label="Crew" />
|
||||||
|
</Group>
|
||||||
|
<Textarea
|
||||||
|
label="Comments"
|
||||||
|
mt="md"
|
||||||
|
autosize
|
||||||
|
minRows={4}
|
||||||
|
{...form.getInputProps("comments")}
|
||||||
|
/>
|
||||||
|
{initialValues?.existing_images?.length ?? 0 > 0 ? (
|
||||||
|
<ImageListInput
|
||||||
|
form={form}
|
||||||
|
field="existing_images"
|
||||||
|
mt="md"
|
||||||
|
label="Existing Images"
|
||||||
|
// canAdd={false}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<ImageUpload
|
||||||
|
form={form}
|
||||||
|
mt="md"
|
||||||
|
field="images"
|
||||||
|
label="Images"
|
||||||
|
placeholder="Upload Images"
|
||||||
|
/>
|
||||||
|
</Fieldset>
|
||||||
|
</Container>
|
||||||
|
</ScrollArea.Autosize>
|
||||||
|
|
||||||
|
<Group justify="flex-end" mt="md">
|
||||||
|
{isPending ? (
|
||||||
|
<Loader />
|
||||||
|
) : isError ? (
|
||||||
|
<Text c="red" fw={700}>
|
||||||
|
{error?.message}
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
{withCancelButton ? (
|
||||||
|
<Button onClick={cancelFunc} color="gray">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button type="submit" leftSection={<IconPencil />}>
|
||||||
|
{submitButtonLabel ?? "Create"}
|
||||||
|
</Button>
|
||||||
|
</Group>
|
||||||
|
</form>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
79
web/app/ui/form/hour-input.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { FlightFormSchema } from "@/util/types";
|
||||||
|
import { CloseButton, NumberInput } from "@mantine/core";
|
||||||
|
import { UseFormReturnType } from "@mantine/form";
|
||||||
|
|
||||||
|
function HourInput({
|
||||||
|
form,
|
||||||
|
field,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
form: UseFormReturnType<
|
||||||
|
FlightFormSchema,
|
||||||
|
(values: FlightFormSchema) => FlightFormSchema
|
||||||
|
>;
|
||||||
|
field: string;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
const field_key = field as keyof typeof form.getTransformedValues;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NumberInput
|
||||||
|
label={label}
|
||||||
|
decimalScale={1}
|
||||||
|
step={0.1}
|
||||||
|
min={0}
|
||||||
|
fixedDecimalScale
|
||||||
|
leftSection={
|
||||||
|
<CloseButton
|
||||||
|
aria-label="Clear input"
|
||||||
|
onClick={() => form.setFieldValue(field, "")}
|
||||||
|
style={{
|
||||||
|
display:
|
||||||
|
["", null].indexOf(form.getTransformedValues()[field_key]) > -1
|
||||||
|
? "none"
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{...form.getInputProps(field)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ZeroHourInput({
|
||||||
|
form,
|
||||||
|
field,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
form: UseFormReturnType<
|
||||||
|
FlightFormSchema,
|
||||||
|
(values: FlightFormSchema) => FlightFormSchema
|
||||||
|
>;
|
||||||
|
field: string;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
const field_key = field as keyof typeof form.getTransformedValues;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NumberInput
|
||||||
|
label={label}
|
||||||
|
decimalScale={1}
|
||||||
|
step={0.1}
|
||||||
|
min={0}
|
||||||
|
fixedDecimalScale
|
||||||
|
leftSection={
|
||||||
|
<CloseButton
|
||||||
|
aria-label="Clear input"
|
||||||
|
onClick={() => form.setFieldValue(field, 0)}
|
||||||
|
style={{
|
||||||
|
display:
|
||||||
|
form.getTransformedValues()[field_key] === 0 ? "none" : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{...form.getInputProps(field)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { HourInput, ZeroHourInput };
|
75
web/app/ui/form/image-list-input.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { Button, Card, Group, Text } from "@mantine/core";
|
||||||
|
import SecureImage from "../display/secure-img";
|
||||||
|
import { randomId } from "@mantine/hooks";
|
||||||
|
import { IconTrash } from "@tabler/icons-react";
|
||||||
|
import { UseFormReturnType } from "@mantine/form";
|
||||||
|
import { FlightFormSchema } from "@/util/types";
|
||||||
|
|
||||||
|
export default function ImageListInput({
|
||||||
|
label,
|
||||||
|
form,
|
||||||
|
field,
|
||||||
|
mt = "sm",
|
||||||
|
h = "100px",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
form: UseFormReturnType<
|
||||||
|
FlightFormSchema,
|
||||||
|
(values: FlightFormSchema) => FlightFormSchema
|
||||||
|
>;
|
||||||
|
field: string;
|
||||||
|
mt?: string;
|
||||||
|
w?: string;
|
||||||
|
h?: string;
|
||||||
|
}) {
|
||||||
|
const field_key = field as keyof typeof form.getTransformedValues;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* <Grid> */}
|
||||||
|
<Text size="sm" fw={700} mt={mt} mb="xs">
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Group display="flex" gap="xs" style={{ flexWrap: "wrap" }}>
|
||||||
|
{(form.getTransformedValues()[field_key] as string[]).map((id) => (
|
||||||
|
// <Grid.Col key={randomId()}>
|
||||||
|
<Card key={randomId()} padding="md" shadow="md" withBorder>
|
||||||
|
{/* <Card.Section> */}
|
||||||
|
<SecureImage id={id} h={h} />
|
||||||
|
{/* </Card.Section> */}
|
||||||
|
<Button
|
||||||
|
mt="md"
|
||||||
|
leftSection={<IconTrash />}
|
||||||
|
onClick={() =>
|
||||||
|
form.setFieldValue(
|
||||||
|
field,
|
||||||
|
(form.getTransformedValues()[field_key] as string[]).filter(
|
||||||
|
(i) => i !== id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
// </Grid.Col>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</>
|
||||||
|
// </Grid>
|
||||||
|
// <PillsInput label={label}>
|
||||||
|
// <Pill.Group>
|
||||||
|
// {imageIds.map((id: string) => (
|
||||||
|
// <Pill
|
||||||
|
// radius="sm"
|
||||||
|
// key={id}
|
||||||
|
// withRemoveButton
|
||||||
|
// onRemove={() => setImageIds(imageIds.filter((i) => i !== id))}
|
||||||
|
// >
|
||||||
|
// <SecureImage id={id} h="20px" />
|
||||||
|
// </Pill>
|
||||||
|
// ))}
|
||||||
|
// </Pill.Group>
|
||||||
|
// </PillsInput>
|
||||||
|
);
|
||||||
|
}
|
54
web/app/ui/form/image-upload.tsx
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { FlightFormSchema } from "@/util/types";
|
||||||
|
import { FileInput, FileInputProps, Pill } from "@mantine/core";
|
||||||
|
import { UseFormReturnType } from "@mantine/form";
|
||||||
|
import { randomId } from "@mantine/hooks";
|
||||||
|
import { IconPhoto } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
export default function ImageUpload({
|
||||||
|
form,
|
||||||
|
field,
|
||||||
|
label = "",
|
||||||
|
placeholder = "",
|
||||||
|
mt = "",
|
||||||
|
}: {
|
||||||
|
form: UseFormReturnType<
|
||||||
|
FlightFormSchema,
|
||||||
|
(values: FlightFormSchema) => FlightFormSchema
|
||||||
|
>;
|
||||||
|
field: string;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
mt?: string;
|
||||||
|
}) {
|
||||||
|
const ValueComponent: FileInputProps["valueComponent"] = ({ value }) => {
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return (
|
||||||
|
<Pill.Group>
|
||||||
|
{value.map((file) => (
|
||||||
|
<Pill key={randomId()}>{file.name}</Pill>
|
||||||
|
))}
|
||||||
|
</Pill.Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Pill>{value.name}</Pill>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FileInput
|
||||||
|
label={label}
|
||||||
|
placeholder={placeholder}
|
||||||
|
multiple
|
||||||
|
mt={mt}
|
||||||
|
accept="image/*"
|
||||||
|
valueComponent={ValueComponent}
|
||||||
|
rightSectionPointerEvents="none"
|
||||||
|
rightSection={<IconPhoto />}
|
||||||
|
{...form.getInputProps(field)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
75
web/app/ui/form/int-input.tsx
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { FlightFormSchema } from "@/util/types";
|
||||||
|
import { CloseButton, NumberInput } from "@mantine/core";
|
||||||
|
import { UseFormReturnType } from "@mantine/form";
|
||||||
|
|
||||||
|
function IntInput({
|
||||||
|
form,
|
||||||
|
field,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
form: UseFormReturnType<
|
||||||
|
FlightFormSchema,
|
||||||
|
(values: FlightFormSchema) => FlightFormSchema
|
||||||
|
>;
|
||||||
|
field: string;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
const field_key = field as keyof typeof form.getTransformedValues;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NumberInput
|
||||||
|
label={label}
|
||||||
|
min={0}
|
||||||
|
allowDecimal={false}
|
||||||
|
leftSection={
|
||||||
|
<CloseButton
|
||||||
|
aria-label="Clear input"
|
||||||
|
onClick={() => form.setFieldValue(field, "")}
|
||||||
|
style={{
|
||||||
|
display:
|
||||||
|
["", null].indexOf(form.getTransformedValues()[field_key]) === 0
|
||||||
|
? "none"
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{...form.getInputProps(field)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ZeroIntInput({
|
||||||
|
form,
|
||||||
|
field,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
form: UseFormReturnType<
|
||||||
|
FlightFormSchema,
|
||||||
|
(values: FlightFormSchema) => FlightFormSchema
|
||||||
|
>;
|
||||||
|
field: string;
|
||||||
|
label: string;
|
||||||
|
}) {
|
||||||
|
const field_key = field as keyof typeof form.getTransformedValues;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NumberInput
|
||||||
|
label={label}
|
||||||
|
min={0}
|
||||||
|
allowDecimal={false}
|
||||||
|
leftSection={
|
||||||
|
<CloseButton
|
||||||
|
aria-label="Clear input"
|
||||||
|
onClick={() => form.setFieldValue(field, 0)}
|
||||||
|
style={{
|
||||||
|
display:
|
||||||
|
form.getTransformedValues()[field_key] === 0 ? "none" : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
{...form.getInputProps(field)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { IntInput, ZeroIntInput };
|
79
web/app/ui/form/list-input.tsx
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import { FlightFormSchema } from "@/util/types";
|
||||||
|
import { Pill, PillsInput } from "@mantine/core";
|
||||||
|
import { UseFormReturnType } from "@mantine/form";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
export default function ListInput({
|
||||||
|
form,
|
||||||
|
field,
|
||||||
|
label,
|
||||||
|
mt = "",
|
||||||
|
canAdd = true,
|
||||||
|
}: {
|
||||||
|
form: UseFormReturnType<
|
||||||
|
FlightFormSchema,
|
||||||
|
(values: FlightFormSchema) => FlightFormSchema
|
||||||
|
>;
|
||||||
|
field: string;
|
||||||
|
label: string;
|
||||||
|
mt?: string;
|
||||||
|
canAdd?: boolean;
|
||||||
|
}) {
|
||||||
|
const field_key = field as keyof typeof form.getTransformedValues;
|
||||||
|
const [inputValue, setInputValue] = useState<string>("");
|
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||||
|
if (event.key === "Enter" || event.key === ",") {
|
||||||
|
event.preventDefault();
|
||||||
|
const values = form.getTransformedValues()[field_key] as string[];
|
||||||
|
const newItem = inputValue.trim();
|
||||||
|
if (newItem && values.indexOf(newItem) == -1) {
|
||||||
|
form.setFieldValue(field, [...values, newItem]);
|
||||||
|
setInputValue("");
|
||||||
|
}
|
||||||
|
} else if (event.key === "Backspace") {
|
||||||
|
const values = form.getTransformedValues()[field_key] as string[];
|
||||||
|
const newItem = inputValue.trim();
|
||||||
|
if (newItem === "") {
|
||||||
|
form.setFieldValue(field, values.slice(0, -1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PillsInput
|
||||||
|
mt={mt}
|
||||||
|
label={label}
|
||||||
|
description={canAdd ? "Press enter or comma to add item" : ""}
|
||||||
|
>
|
||||||
|
<Pill.Group>
|
||||||
|
{(form.getTransformedValues()[field_key] as string[]).map(
|
||||||
|
(item: string) => (
|
||||||
|
<Pill
|
||||||
|
radius="sm"
|
||||||
|
key={item}
|
||||||
|
withRemoveButton
|
||||||
|
onRemove={() =>
|
||||||
|
form.setFieldValue(
|
||||||
|
field,
|
||||||
|
(form.getTransformedValues()[field_key] as string[]).filter(
|
||||||
|
(value: string) => value !== item
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</Pill>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
{canAdd ? (
|
||||||
|
<PillsInput.Field
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(event) => setInputValue(event.currentTarget.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Pill.Group>
|
||||||
|
</PillsInput>
|
||||||
|
);
|
||||||
|
}
|
55
web/app/ui/form/time-input.tsx
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
import { FlightFormSchema } from "@/util/types";
|
||||||
|
import { ActionIcon, CloseButton, NumberInput, Tooltip } from "@mantine/core";
|
||||||
|
import { UseFormReturnType } from "@mantine/form";
|
||||||
|
import { IconClock } from "@tabler/icons-react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
|
||||||
|
export default function TimeInput({
|
||||||
|
form,
|
||||||
|
label,
|
||||||
|
field,
|
||||||
|
allowLeadingZeros = false,
|
||||||
|
}: {
|
||||||
|
form: UseFormReturnType<FlightFormSchema>;
|
||||||
|
field: string;
|
||||||
|
label: string;
|
||||||
|
allowLeadingZeros?: boolean;
|
||||||
|
}) {
|
||||||
|
const field_key = field as keyof typeof form.getTransformedValues;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<NumberInput
|
||||||
|
label={label}
|
||||||
|
allowDecimal={false}
|
||||||
|
min={0}
|
||||||
|
max={2359}
|
||||||
|
allowLeadingZeros={allowLeadingZeros}
|
||||||
|
leftSection={
|
||||||
|
<CloseButton
|
||||||
|
aria-label="Clear input"
|
||||||
|
onClick={() => form.setFieldValue(field, "")}
|
||||||
|
style={{
|
||||||
|
display:
|
||||||
|
["", null].indexOf(form.getTransformedValues()[field_key]) > -1
|
||||||
|
? "none"
|
||||||
|
: undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
rightSection={
|
||||||
|
<Tooltip label="Now">
|
||||||
|
<ActionIcon
|
||||||
|
variant="transparent"
|
||||||
|
mr="sm"
|
||||||
|
onClick={() => {
|
||||||
|
form.setFieldValue(field, dayjs().format("HHmm"));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconClock style={{ width: "70%", height: "70%" }} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
{...form.getInputProps(field)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
71
web/app/ui/input/hour-input.tsx
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { CloseButton, NumberInput } from "@mantine/core";
|
||||||
|
import { Dispatch, SetStateAction } from "react";
|
||||||
|
|
||||||
|
function HourInput({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
setValue,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number | string | null;
|
||||||
|
setValue: Dispatch<SetStateAction<string | number | null>>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NumberInput
|
||||||
|
label={label}
|
||||||
|
decimalScale={1}
|
||||||
|
step={0.1}
|
||||||
|
min={0}
|
||||||
|
fixedDecimalScale
|
||||||
|
leftSection={
|
||||||
|
<CloseButton
|
||||||
|
aria-label="Clear input"
|
||||||
|
onClick={() => setValue("")}
|
||||||
|
style={{
|
||||||
|
display: Number.isFinite(value) ? undefined : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onChange={(value) =>
|
||||||
|
setValue(Number.isFinite(value) ? (value as number) : 0)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ZeroHourInput({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
setValue,
|
||||||
|
error = null,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number | null;
|
||||||
|
setValue: Dispatch<SetStateAction<number | null>>;
|
||||||
|
error?: string | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NumberInput
|
||||||
|
label={label}
|
||||||
|
decimalScale={1}
|
||||||
|
step={0.1}
|
||||||
|
min={0}
|
||||||
|
fixedDecimalScale
|
||||||
|
error={error}
|
||||||
|
leftSection={
|
||||||
|
<CloseButton
|
||||||
|
aria-label="Clear input"
|
||||||
|
onClick={() => setValue(0)}
|
||||||
|
style={{
|
||||||
|
display: !value || value === 0 ? "none" : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onChange={(value) =>
|
||||||
|
setValue(Number.isFinite(value) ? (value as number) : 0)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { HourInput, ZeroHourInput };
|
65
web/app/ui/input/image-list-input.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
Collapse,
|
||||||
|
Group,
|
||||||
|
Text,
|
||||||
|
Tooltip,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { Dispatch, SetStateAction, useState } from "react";
|
||||||
|
import SecureImage from "../display/secure-img";
|
||||||
|
import { randomId } from "@mantine/hooks";
|
||||||
|
import { IconMinus, IconPlus, IconTrash } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
export default function ImageListInput({
|
||||||
|
label,
|
||||||
|
imageIds,
|
||||||
|
setImageIds,
|
||||||
|
collapsible = false,
|
||||||
|
startCollapsed = true,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
imageIds: string[];
|
||||||
|
setImageIds: Dispatch<SetStateAction<string[]>>;
|
||||||
|
collapsible?: boolean;
|
||||||
|
startCollapsed?: boolean;
|
||||||
|
}) {
|
||||||
|
const [collapsed, setCollapsed] = useState(startCollapsed);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Group gap="0">
|
||||||
|
<Text size="sm" fw={700} span>
|
||||||
|
{label}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
{collapsible ? (
|
||||||
|
<Tooltip label={collapsed ? "Expand" : "Collapse"}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="transparent"
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
>
|
||||||
|
{collapsed ? <IconPlus size="1rem" /> : <IconMinus size="1rem" />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
</Group>
|
||||||
|
<Collapse in={!collapsed}>
|
||||||
|
<Group variant="column">
|
||||||
|
{imageIds.map((id) => (
|
||||||
|
<Card key={randomId()} padding="md" shadow="md" withBorder>
|
||||||
|
<SecureImage id={id} />
|
||||||
|
<Button
|
||||||
|
mt="md"
|
||||||
|
leftSection={<IconTrash />}
|
||||||
|
onClick={() => setImageIds(imageIds.filter((i) => i !== id))}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
|
</Collapse>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
52
web/app/ui/input/image-upload.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { FlightFormSchema } from "@/util/types";
|
||||||
|
import { FileInput, FileInputProps, Pill } from "@mantine/core";
|
||||||
|
import { UseFormReturnType } from "@mantine/form";
|
||||||
|
import { randomId } from "@mantine/hooks";
|
||||||
|
import { IconPhoto } from "@tabler/icons-react";
|
||||||
|
import { Dispatch, SetStateAction } from "react";
|
||||||
|
|
||||||
|
export default function ImageUpload({
|
||||||
|
value,
|
||||||
|
setValue,
|
||||||
|
label = "",
|
||||||
|
placeholder = "",
|
||||||
|
mt = "",
|
||||||
|
}: {
|
||||||
|
value: File[];
|
||||||
|
setValue: Dispatch<SetStateAction<File[]>>;
|
||||||
|
label?: string;
|
||||||
|
placeholder?: string;
|
||||||
|
mt?: string;
|
||||||
|
}) {
|
||||||
|
const ValueComponent: FileInputProps["valueComponent"] = ({ value }) => {
|
||||||
|
if (value === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return (
|
||||||
|
<Pill.Group>
|
||||||
|
{value.map((file) => (
|
||||||
|
<Pill key={randomId()}>{file.name}</Pill>
|
||||||
|
))}
|
||||||
|
</Pill.Group>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Pill>{value.name}</Pill>;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FileInput
|
||||||
|
label={label}
|
||||||
|
placeholder={placeholder}
|
||||||
|
multiple
|
||||||
|
mt={mt}
|
||||||
|
accept="image/*"
|
||||||
|
valueComponent={ValueComponent}
|
||||||
|
rightSectionPointerEvents="none"
|
||||||
|
rightSection={<IconPhoto />}
|
||||||
|
onChange={setValue}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
65
web/app/ui/input/int-input.tsx
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
import { CloseButton, NumberInput } from "@mantine/core";
|
||||||
|
import { Dispatch, SetStateAction } from "react";
|
||||||
|
|
||||||
|
function IntInput({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
setValue,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number | string | undefined;
|
||||||
|
setValue: Dispatch<SetStateAction<number | string | undefined>>;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NumberInput
|
||||||
|
label={label}
|
||||||
|
min={0}
|
||||||
|
allowDecimal={false}
|
||||||
|
value={value}
|
||||||
|
leftSection={
|
||||||
|
<CloseButton
|
||||||
|
aria-label="Clear input"
|
||||||
|
onClick={() => setValue("")}
|
||||||
|
style={{
|
||||||
|
display: Number.isFinite(value) ? "none" : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onChange={setValue}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ZeroIntInput({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
setValue,
|
||||||
|
error = null,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number;
|
||||||
|
setValue: Dispatch<SetStateAction<number>>;
|
||||||
|
error: string | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NumberInput
|
||||||
|
label={label}
|
||||||
|
min={0}
|
||||||
|
allowDecimal={false}
|
||||||
|
error={error}
|
||||||
|
value={value}
|
||||||
|
leftSection={
|
||||||
|
<CloseButton
|
||||||
|
aria-label="Clear input"
|
||||||
|
onClick={() => setValue(0)}
|
||||||
|
style={{
|
||||||
|
display: !value || value === 0 ? "none" : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
onChange={(value) => setValue(Number(value))}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { IntInput, ZeroIntInput };
|
61
web/app/ui/input/list-input.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
import { Pill, PillsInput } from "@mantine/core";
|
||||||
|
import { Dispatch, SetStateAction, useState } from "react";
|
||||||
|
|
||||||
|
export default function ListInput({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
setValue,
|
||||||
|
canAdd = true,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: string[];
|
||||||
|
setValue: Dispatch<SetStateAction<string[]>>;
|
||||||
|
canAdd?: boolean;
|
||||||
|
}) {
|
||||||
|
const [inputValue, setInputValue] = useState<string>("");
|
||||||
|
|
||||||
|
const handleKeyDown = (event: React.KeyboardEvent) => {
|
||||||
|
if (event.key === "Enter" || event.key === ",") {
|
||||||
|
event.preventDefault();
|
||||||
|
const newItem = inputValue.trim();
|
||||||
|
if (newItem && value.indexOf(newItem) == -1) {
|
||||||
|
setValue([...value, newItem]);
|
||||||
|
setInputValue("");
|
||||||
|
}
|
||||||
|
} else if (event.key === "Backspace") {
|
||||||
|
const newItem = inputValue.trim();
|
||||||
|
if (newItem === "") {
|
||||||
|
setValue(value.slice(0, -1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PillsInput
|
||||||
|
label={label}
|
||||||
|
description={canAdd ? "Press enter or comma to add item" : ""}
|
||||||
|
>
|
||||||
|
<Pill.Group>
|
||||||
|
{value.map((item: string) => (
|
||||||
|
<Pill
|
||||||
|
radius="sm"
|
||||||
|
key={item}
|
||||||
|
withRemoveButton
|
||||||
|
onRemove={() =>
|
||||||
|
setValue(value.filter((value: string) => value !== item))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</Pill>
|
||||||
|
))}
|
||||||
|
{canAdd ? (
|
||||||
|
<PillsInput.Field
|
||||||
|
value={inputValue}
|
||||||
|
onChange={(event) => setInputValue(event.currentTarget.value)}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Pill.Group>
|
||||||
|
</PillsInput>
|
||||||
|
);
|
||||||
|
}
|
53
web/app/ui/input/time-input.tsx
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
import { ActionIcon, CloseButton, NumberInput, Tooltip } from "@mantine/core";
|
||||||
|
import { IconClock } from "@tabler/icons-react";
|
||||||
|
import dayjs from "dayjs";
|
||||||
|
import { Dispatch, SetStateAction } from "react";
|
||||||
|
|
||||||
|
export default function TimeInput({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
setValue,
|
||||||
|
allowLeadingZeros = false,
|
||||||
|
error = "",
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: number | string | undefined;
|
||||||
|
setValue: Dispatch<SetStateAction<number | string | undefined>>;
|
||||||
|
allowLeadingZeros?: boolean;
|
||||||
|
error?: string | null;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<NumberInput
|
||||||
|
label={label}
|
||||||
|
allowDecimal={false}
|
||||||
|
min={0}
|
||||||
|
max={2359}
|
||||||
|
allowLeadingZeros={allowLeadingZeros}
|
||||||
|
error={error}
|
||||||
|
value={value}
|
||||||
|
leftSection={
|
||||||
|
<CloseButton
|
||||||
|
aria-label="Clear input"
|
||||||
|
onClick={() => setValue("")}
|
||||||
|
style={{
|
||||||
|
display: Number.isFinite(value) ? "none" : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
rightSection={
|
||||||
|
<Tooltip label="Now">
|
||||||
|
<ActionIcon
|
||||||
|
variant="transparent"
|
||||||
|
mr="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setValue(dayjs().format("HHmm"));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<IconClock style={{ width: "70%", height: "70%" }} />
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
}
|
||||||
|
onChange={setValue}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
52
web/app/ui/nav/app-shell.tsx
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import {
|
||||||
|
AppShell,
|
||||||
|
Burger,
|
||||||
|
Group,
|
||||||
|
Title,
|
||||||
|
UnstyledButton,
|
||||||
|
Avatar,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
|
import ThemeToggle from "../theme-toggle";
|
||||||
|
import { Link, useNavigate } from "@remix-run/react";
|
||||||
|
import Navbar from "./navbar";
|
||||||
|
|
||||||
|
export function TailfinAppShell({ children }: { children: React.ReactNode }) {
|
||||||
|
const [opened, { toggle }] = useDisclosure();
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppShell
|
||||||
|
header={{ height: 60 }}
|
||||||
|
navbar={{ width: 300, breakpoint: "xl", collapsed: { mobile: !opened } }}
|
||||||
|
padding="md"
|
||||||
|
>
|
||||||
|
<AppShell.Header>
|
||||||
|
<Group h="100%" justify="space-between" px="md">
|
||||||
|
<Group h="100%" px="md">
|
||||||
|
<Burger
|
||||||
|
opened={opened}
|
||||||
|
onClick={toggle}
|
||||||
|
hiddenFrom="xl"
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
</Group>
|
||||||
|
<Group gap="xs">
|
||||||
|
<Avatar src="/logo.png" component={Link} to="/logbook" />
|
||||||
|
<UnstyledButton onClick={() => navigate("/logbook")}>
|
||||||
|
<Title order={2} fw="normal">
|
||||||
|
Tailfin
|
||||||
|
</Title>
|
||||||
|
</UnstyledButton>
|
||||||
|
</Group>
|
||||||
|
<ThemeToggle />
|
||||||
|
</Group>
|
||||||
|
</AppShell.Header>
|
||||||
|
<AppShell.Navbar>
|
||||||
|
<Navbar opened={opened} toggle={toggle} />
|
||||||
|
</AppShell.Navbar>
|
||||||
|
<AppShell.Main>{children}</AppShell.Main>
|
||||||
|
</AppShell>
|
||||||
|
);
|
||||||
|
}
|
92
web/app/ui/nav/navbar.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { useAuth } from "@/util/auth";
|
||||||
|
import { Stack, NavLink } from "@mantine/core";
|
||||||
|
import { Link, useLocation } from "@remix-run/react";
|
||||||
|
import {
|
||||||
|
IconAdjustments,
|
||||||
|
IconBook2,
|
||||||
|
IconDashboard,
|
||||||
|
IconLogout,
|
||||||
|
IconPlaneTilt,
|
||||||
|
IconUser,
|
||||||
|
} from "@tabler/icons-react";
|
||||||
|
|
||||||
|
export default function Navbar({
|
||||||
|
opened,
|
||||||
|
toggle,
|
||||||
|
}: {
|
||||||
|
opened: boolean;
|
||||||
|
toggle: () => void;
|
||||||
|
}) {
|
||||||
|
const location = useLocation();
|
||||||
|
const page = location.pathname.split("/")[2];
|
||||||
|
|
||||||
|
const { user, authLevel, signout } = useAuth();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Stack justify="space-between" h="100%">
|
||||||
|
<Stack gap="0">
|
||||||
|
<NavLink
|
||||||
|
p="md"
|
||||||
|
component={Link}
|
||||||
|
to="/logbook/dashboard"
|
||||||
|
label="Dashboard"
|
||||||
|
leftSection={<IconDashboard />}
|
||||||
|
active={page == "dashboard"}
|
||||||
|
onClick={() => (opened ? toggle() : null)}
|
||||||
|
/>
|
||||||
|
<NavLink
|
||||||
|
p="md"
|
||||||
|
component={Link}
|
||||||
|
to="/logbook/flights"
|
||||||
|
label="Flights"
|
||||||
|
leftSection={<IconBook2 />}
|
||||||
|
active={page === "flights"}
|
||||||
|
onClick={() => (opened ? toggle() : null)}
|
||||||
|
/>
|
||||||
|
<NavLink
|
||||||
|
p="md"
|
||||||
|
component={Link}
|
||||||
|
to="/logbook/aircraft"
|
||||||
|
label="Aircraft"
|
||||||
|
leftSection={<IconPlaneTilt />}
|
||||||
|
active={page === "aircraft"}
|
||||||
|
onClick={() => (opened ? toggle() : null)}
|
||||||
|
/>
|
||||||
|
{authLevel ? (
|
||||||
|
authLevel === 2 ? (
|
||||||
|
<NavLink
|
||||||
|
p="md"
|
||||||
|
component={Link}
|
||||||
|
to="/logbook/admin"
|
||||||
|
label="Admin"
|
||||||
|
leftSection={<IconAdjustments />}
|
||||||
|
active={page === "admin"}
|
||||||
|
onClick={() => (opened ? toggle() : null)}
|
||||||
|
/>
|
||||||
|
) : null
|
||||||
|
) : null}
|
||||||
|
</Stack>
|
||||||
|
<Stack gap="0">
|
||||||
|
<NavLink
|
||||||
|
p="md"
|
||||||
|
label={user ? user : "Not Logged In"}
|
||||||
|
leftSection={<IconUser />}
|
||||||
|
>
|
||||||
|
<NavLink
|
||||||
|
p="md"
|
||||||
|
component={Link}
|
||||||
|
to="/logbook/me"
|
||||||
|
label="Account"
|
||||||
|
leftSection={<IconUser />}
|
||||||
|
/>
|
||||||
|
<NavLink
|
||||||
|
p="md"
|
||||||
|
onClick={() => signout()}
|
||||||
|
label="Sign Out"
|
||||||
|
leftSection={<IconLogout />}
|
||||||
|
/>
|
||||||
|
</NavLink>
|
||||||
|
</Stack>
|
||||||
|
</Stack>
|
||||||
|
);
|
||||||
|
}
|
30
web/app/ui/theme-toggle.tsx
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
import {
|
||||||
|
ActionIcon,
|
||||||
|
Tooltip,
|
||||||
|
useComputedColorScheme,
|
||||||
|
useMantineColorScheme,
|
||||||
|
} from "@mantine/core";
|
||||||
|
import { IconMoonStars, IconSun } from "@tabler/icons-react";
|
||||||
|
|
||||||
|
const ThemeToggle = () => {
|
||||||
|
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||||
|
const comoputedColorScheme = useComputedColorScheme("dark");
|
||||||
|
const toggleColorScheme = () => {
|
||||||
|
setColorScheme(comoputedColorScheme === "dark" ? "light" : "dark");
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Tooltip label={(colorScheme === "dark" ? "Light" : "Dark") + " Theme"}>
|
||||||
|
<ActionIcon
|
||||||
|
variant="default"
|
||||||
|
radius="xl"
|
||||||
|
size="lg"
|
||||||
|
aria-label="Toggle Dark Theme"
|
||||||
|
onClick={toggleColorScheme}
|
||||||
|
>
|
||||||
|
{colorScheme === "dark" ? <IconSun /> : <IconMoonStars />}
|
||||||
|
</ActionIcon>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ThemeToggle;
|
64
web/app/util/api.tsx
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import axios, { AxiosInstance } from "axios";
|
||||||
|
import { createContext, useContext } from "react";
|
||||||
|
|
||||||
|
const ApiContext = createContext<AxiosInstance | null>(null);
|
||||||
|
|
||||||
|
export function ApiProvider({
|
||||||
|
children,
|
||||||
|
apiUrl,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
apiUrl: string;
|
||||||
|
}) {
|
||||||
|
const api = useProvideApi(apiUrl);
|
||||||
|
return <ApiContext.Provider value={api}>{children}</ApiContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useApi(): AxiosInstance {
|
||||||
|
const api = useContext(ApiContext);
|
||||||
|
if (!api) throw new Error("Could not find API provider");
|
||||||
|
|
||||||
|
return api;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useProvideApi(apiUrl: string) {
|
||||||
|
const client = axios.create({
|
||||||
|
baseURL: apiUrl,
|
||||||
|
headers: { "Access-Control-Allow-Origin": "*" },
|
||||||
|
});
|
||||||
|
|
||||||
|
client.interceptors.request.use(
|
||||||
|
(config) => {
|
||||||
|
const token = localStorage.getItem("token");
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
},
|
||||||
|
async (error) => {
|
||||||
|
console.log(error.response);
|
||||||
|
if (error.response && error.response.status === 401) {
|
||||||
|
try {
|
||||||
|
const refreshToken = localStorage.getItem("refresh-token");
|
||||||
|
const response = await client.post("/auth/refresh", { refreshToken });
|
||||||
|
const newAccessToken = response.data.access_token;
|
||||||
|
|
||||||
|
localStorage.setItem("token", newAccessToken);
|
||||||
|
error.config.headers.Authorization = `Bearer ${newAccessToken}`;
|
||||||
|
return client(error.config);
|
||||||
|
} catch (err) {
|
||||||
|
localStorage.removeItem("token");
|
||||||
|
localStorage.removeItem("refresh-token");
|
||||||
|
window.location.href = "/login";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createClient() {
|
||||||
|
return axios.create({});
|
||||||
|
}
|
123
web/app/util/auth.tsx
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import { useApi } from "./api";
|
||||||
|
import { useNavigate } from "@remix-run/react";
|
||||||
|
import { AxiosError } from "axios";
|
||||||
|
import { createContext, useContext, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface AuthContextValues {
|
||||||
|
user: string | null;
|
||||||
|
authLevel: number | null;
|
||||||
|
loading: boolean;
|
||||||
|
signin: ({
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
}: {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
}) => Promise<string | void>;
|
||||||
|
signout: () => void;
|
||||||
|
clearUser: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValues | null>(null);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const auth = useProvideAuth();
|
||||||
|
return <AuthContext.Provider value={auth}>{children}</AuthContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValues {
|
||||||
|
const data = useContext(AuthContext);
|
||||||
|
if (!data) {
|
||||||
|
throw new Error("Could not find AuthContext provider");
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useProvideAuth() {
|
||||||
|
const [user, setUser] = useState<string | null>(null);
|
||||||
|
const [authLevel, setAuthLevel] = useState<number | null>(null);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const client = useApi();
|
||||||
|
|
||||||
|
const handleUser = (rawUser: string | null) => {
|
||||||
|
if (rawUser) {
|
||||||
|
setUser(rawUser);
|
||||||
|
setLoading(false);
|
||||||
|
return rawUser;
|
||||||
|
} else {
|
||||||
|
setUser(null);
|
||||||
|
clearTokens();
|
||||||
|
setLoading(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTokens = (tokens: {
|
||||||
|
access_token: string;
|
||||||
|
refresh_token: string;
|
||||||
|
}) => {
|
||||||
|
if (tokens) {
|
||||||
|
localStorage.setItem("token", tokens.access_token);
|
||||||
|
localStorage.setItem("refresh-token", tokens.refresh_token);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearTokens = () => {
|
||||||
|
localStorage.removeItem("token");
|
||||||
|
localStorage.removeItem("refresh-token");
|
||||||
|
};
|
||||||
|
|
||||||
|
const signin = async (values: { username: string; password: string }) => {
|
||||||
|
setLoading(true);
|
||||||
|
await client
|
||||||
|
.postForm("/auth/login", values)
|
||||||
|
.then((response) => handleTokens(response.data))
|
||||||
|
.catch((err: AxiosError) => {
|
||||||
|
setLoading(false);
|
||||||
|
if (err.response?.status === 401)
|
||||||
|
throw new Error("Invalid username or password");
|
||||||
|
throw new Error(err.message);
|
||||||
|
});
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
|
await client
|
||||||
|
.get("/users/me")
|
||||||
|
.then((response) => {
|
||||||
|
handleUser(response.data.username);
|
||||||
|
setAuthLevel(response.data.level);
|
||||||
|
})
|
||||||
|
.catch(() => handleUser(null));
|
||||||
|
navigate("/logbook");
|
||||||
|
};
|
||||||
|
|
||||||
|
const signout = async () => {
|
||||||
|
return await client.post("/auth/logout").then(() => handleUser(null));
|
||||||
|
};
|
||||||
|
|
||||||
|
const clearUser = () => {
|
||||||
|
handleUser(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
client
|
||||||
|
.get("/users/me")
|
||||||
|
.then((response) => {
|
||||||
|
handleUser(response.data.username);
|
||||||
|
setAuthLevel(response.data.level);
|
||||||
|
})
|
||||||
|
.catch(() => handleUser(null));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
authLevel,
|
||||||
|
loading,
|
||||||
|
signin,
|
||||||
|
signout,
|
||||||
|
clearUser,
|
||||||
|
};
|
||||||
|
}
|
54
web/app/util/hooks.ts
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { useApi } from "./api";
|
||||||
|
|
||||||
|
export function useAircraft() {
|
||||||
|
const client = useApi();
|
||||||
|
|
||||||
|
const aircraft = useQuery({
|
||||||
|
queryKey: ["aircraft-list"],
|
||||||
|
queryFn: async () => await client.get(`/aircraft`).then((res) => res.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
return aircraft;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useFlights(filter: string = "", value: string = "") {
|
||||||
|
const client = useApi();
|
||||||
|
|
||||||
|
const flights = useQuery({
|
||||||
|
queryKey: ["flights-list"],
|
||||||
|
queryFn: async () =>
|
||||||
|
await client
|
||||||
|
.get(
|
||||||
|
`/flights/by-date?order=1${
|
||||||
|
filter !== "" && value !== ""
|
||||||
|
? `&filter=${filter}&value=${value}`
|
||||||
|
: ""
|
||||||
|
}`
|
||||||
|
)
|
||||||
|
.then((res) => res.data),
|
||||||
|
});
|
||||||
|
|
||||||
|
return flights;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePatchFlight(
|
||||||
|
id: string,
|
||||||
|
field: string,
|
||||||
|
onSuccess: () => void
|
||||||
|
) {
|
||||||
|
const client = useApi();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
return useMutation({
|
||||||
|
mutationFn: async (value: string | string[] | number | Date | null) =>
|
||||||
|
await client
|
||||||
|
.patch(`/flights/${id}`, { [field]: value })
|
||||||
|
.then((res) => res.data),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: [id] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["flights-list"] });
|
||||||
|
onSuccess();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
208
web/app/util/types.ts
Normal file
@ -0,0 +1,208 @@
|
|||||||
|
import dayjs from "dayjs";
|
||||||
|
import utc from "dayjs/plugin/utc.js";
|
||||||
|
|
||||||
|
dayjs.extend(utc);
|
||||||
|
|
||||||
|
/* FLIGHTS */
|
||||||
|
|
||||||
|
type FlightBaseSchema = {
|
||||||
|
aircraft: string | null;
|
||||||
|
waypoint_from: string | null;
|
||||||
|
waypoint_to: string | null;
|
||||||
|
route: string | null;
|
||||||
|
|
||||||
|
hobbs_start: number | null;
|
||||||
|
hobbs_end: number | null;
|
||||||
|
|
||||||
|
time_total: number;
|
||||||
|
time_pic: number;
|
||||||
|
time_sic: number;
|
||||||
|
time_night: number;
|
||||||
|
time_solo: number;
|
||||||
|
|
||||||
|
time_xc: number;
|
||||||
|
dist_xc: number;
|
||||||
|
|
||||||
|
landings_day: number;
|
||||||
|
landings_night: number;
|
||||||
|
|
||||||
|
time_instrument: number;
|
||||||
|
time_sim_instrument: number;
|
||||||
|
holds_instrument: number;
|
||||||
|
|
||||||
|
dual_given: number;
|
||||||
|
dual_recvd: number;
|
||||||
|
time_sim: number;
|
||||||
|
time_ground: number;
|
||||||
|
|
||||||
|
tags: string[];
|
||||||
|
|
||||||
|
pax: string[];
|
||||||
|
crew: string[];
|
||||||
|
|
||||||
|
comments: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FlightFormSchema = FlightBaseSchema & {
|
||||||
|
date: dayjs.Dayjs;
|
||||||
|
|
||||||
|
time_start: number | null;
|
||||||
|
time_off: number | null;
|
||||||
|
time_down: number | null;
|
||||||
|
time_stop: number | null;
|
||||||
|
|
||||||
|
existing_images?: string[] | null;
|
||||||
|
images: File[] | string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type FlightCreateSchema = FlightBaseSchema & {
|
||||||
|
date: string;
|
||||||
|
|
||||||
|
time_start: string | null;
|
||||||
|
time_off: string | null;
|
||||||
|
time_down: string | null;
|
||||||
|
time_stop: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FlightDisplaySchema = FlightBaseSchema & {
|
||||||
|
id: string;
|
||||||
|
user: string;
|
||||||
|
date: dayjs.Dayjs;
|
||||||
|
|
||||||
|
time_start: number | null;
|
||||||
|
time_off: number | null;
|
||||||
|
time_down: number | null;
|
||||||
|
time_stop: number | null;
|
||||||
|
|
||||||
|
images: string[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FlightConciseSchema = {
|
||||||
|
user: string;
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
date: string;
|
||||||
|
aircraft: string;
|
||||||
|
waypoint_from: string;
|
||||||
|
waypoint_to: string;
|
||||||
|
|
||||||
|
time_total: number;
|
||||||
|
|
||||||
|
comments: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const flightCreateHelper = (
|
||||||
|
values: FlightFormSchema
|
||||||
|
): FlightCreateSchema | void => {
|
||||||
|
const date = dayjs(values.date);
|
||||||
|
try {
|
||||||
|
const newFlight = {
|
||||||
|
...values,
|
||||||
|
date: date.utc().startOf("day").toISOString(),
|
||||||
|
hobbs_start: values.hobbs_start ? Number(values.hobbs_start) : null,
|
||||||
|
hobbs_end: values.hobbs_end ? Number(values.hobbs_end) : null,
|
||||||
|
time_start: values.time_start
|
||||||
|
? date
|
||||||
|
.utc()
|
||||||
|
.hour(Math.floor((values.time_start ?? 0) / 100))
|
||||||
|
.minute(Math.floor((values.time_start ?? 0) % 100))
|
||||||
|
.second(0)
|
||||||
|
.millisecond(0)
|
||||||
|
.toISOString()
|
||||||
|
: null,
|
||||||
|
time_off: values.time_off
|
||||||
|
? date
|
||||||
|
.utc()
|
||||||
|
.hour(Math.floor((values.time_off ?? 0) / 100))
|
||||||
|
.minute(Math.floor((values.time_off ?? 0) % 100))
|
||||||
|
.second(0)
|
||||||
|
.millisecond(0)
|
||||||
|
.toISOString()
|
||||||
|
: null,
|
||||||
|
time_down: values.time_down
|
||||||
|
? date
|
||||||
|
.utc()
|
||||||
|
.hour(Math.floor((values.time_down ?? 0) / 100))
|
||||||
|
.minute(Math.floor((values.time_down ?? 0) % 100))
|
||||||
|
.second(0)
|
||||||
|
.millisecond(0)
|
||||||
|
.toISOString()
|
||||||
|
: null,
|
||||||
|
time_stop: values.time_stop
|
||||||
|
? date
|
||||||
|
.utc()
|
||||||
|
.hour(Math.floor((values.time_stop ?? 0) / 100))
|
||||||
|
.minute(Math.floor((values.time_stop ?? 0) % 100))
|
||||||
|
.second(0)
|
||||||
|
.millisecond(0)
|
||||||
|
.toISOString()
|
||||||
|
: null,
|
||||||
|
};
|
||||||
|
return newFlight;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const flightEditHelper = (
|
||||||
|
values: FlightDisplaySchema
|
||||||
|
): FlightFormSchema | void => {
|
||||||
|
try {
|
||||||
|
const flight = {
|
||||||
|
...values,
|
||||||
|
date: dayjs(values.date),
|
||||||
|
time_start: Number(
|
||||||
|
`${dayjs(values.time_start).hour()}${dayjs(values.time_start).minute()}`
|
||||||
|
),
|
||||||
|
time_off: Number(
|
||||||
|
`${dayjs(values.time_off).hour()}${dayjs(values.time_off).minute()}`
|
||||||
|
),
|
||||||
|
time_down: Number(
|
||||||
|
`${dayjs(values.time_down).hour()}${dayjs(values.time_down).minute()}`
|
||||||
|
),
|
||||||
|
time_stop: Number(
|
||||||
|
`${dayjs(values.time_stop).hour()}${dayjs(values.time_stop).minute()}`
|
||||||
|
),
|
||||||
|
existing_images: values.images as string[],
|
||||||
|
images: [],
|
||||||
|
};
|
||||||
|
return flight;
|
||||||
|
} catch (err) {
|
||||||
|
console.log(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/* AIRCRAFT */
|
||||||
|
|
||||||
|
type AircraftSchema = {
|
||||||
|
id: string;
|
||||||
|
|
||||||
|
tail_no: string;
|
||||||
|
make: string;
|
||||||
|
model: string;
|
||||||
|
aircraft_category: string;
|
||||||
|
aircraft_class: string;
|
||||||
|
|
||||||
|
hobbs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AircraftFormSchema = {
|
||||||
|
tail_no: string;
|
||||||
|
make: string;
|
||||||
|
model: string;
|
||||||
|
aircraft_category: string;
|
||||||
|
aircraft_class: string;
|
||||||
|
|
||||||
|
hobbs: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export {
|
||||||
|
flightEditHelper,
|
||||||
|
flightCreateHelper,
|
||||||
|
type FlightFormSchema,
|
||||||
|
type FlightCreateSchema,
|
||||||
|
type FlightDisplaySchema,
|
||||||
|
type FlightConciseSchema,
|
||||||
|
type AircraftSchema,
|
||||||
|
type AircraftFormSchema,
|
||||||
|
};
|
12653
web/package-lock.json
generated
Normal file
61
web/package.json
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
{
|
||||||
|
"name": "tailfin-web",
|
||||||
|
"private": true,
|
||||||
|
"sideEffects": false,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "remix build",
|
||||||
|
"dev": "remix dev --manual",
|
||||||
|
"lint": "eslint --ignore-path .gitignore --cache --cache-location ./node_modules/.cache/eslint .",
|
||||||
|
"start": "remix-serve ./build/index.js",
|
||||||
|
"typecheck": "tsc"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@mantine/carousel": "^7.4.1",
|
||||||
|
"@mantine/core": "^7.4.1",
|
||||||
|
"@mantine/dates": "^7.4.1",
|
||||||
|
"@mantine/dropzone": "^7.4.1",
|
||||||
|
"@mantine/form": "^7.4.1",
|
||||||
|
"@mantine/hooks": "^7.4.1",
|
||||||
|
"@mantine/modals": "^7.4.1",
|
||||||
|
"@mantine/notifications": "^7.4.1",
|
||||||
|
"@remix-run/css-bundle": "^2.4.1",
|
||||||
|
"@remix-run/node": "^2.4.1",
|
||||||
|
"@remix-run/react": "^2.4.1",
|
||||||
|
"@remix-run/serve": "^2.4.1",
|
||||||
|
"@tabler/icons-react": "^2.44.0",
|
||||||
|
"@tanstack/react-query": "^5.17.0",
|
||||||
|
"@tanstack/react-query-devtools": "^5.17.0",
|
||||||
|
"axios": "^1.6.3",
|
||||||
|
"clsx": "^2.1.0",
|
||||||
|
"dayjs": "^1.11.10",
|
||||||
|
"dayjs-plugin-utc": "^0.1.2",
|
||||||
|
"embla-carousel-react": "^7.1.0",
|
||||||
|
"isbot": "^3.6.8",
|
||||||
|
"mantine-datatable": "^7.4.4",
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"use-dehydrated-state": "^0.1.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@remix-run/dev": "^2.4.1",
|
||||||
|
"@tanstack/eslint-plugin-query": "^5.14.6",
|
||||||
|
"@types/react": "^18.2.20",
|
||||||
|
"@types/react-dom": "^18.2.7",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^6.7.4",
|
||||||
|
"eslint": "^8.38.0",
|
||||||
|
"eslint-config-prettier": "^9.0.0",
|
||||||
|
"eslint-import-resolver-typescript": "^3.6.1",
|
||||||
|
"eslint-plugin-import": "^2.28.1",
|
||||||
|
"eslint-plugin-jsx-a11y": "^6.7.1",
|
||||||
|
"eslint-plugin-react": "^7.33.2",
|
||||||
|
"eslint-plugin-react-hooks": "^4.6.0",
|
||||||
|
"postcss": "^8.4.32",
|
||||||
|
"postcss-preset-mantine": "^1.12.2",
|
||||||
|
"postcss-simple-vars": "^7.0.1",
|
||||||
|
"typescript": "^5.1.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18.0.0"
|
||||||
|
}
|
||||||
|
}
|
14
web/postcss.config.cjs
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
module.exports = {
|
||||||
|
plugins: {
|
||||||
|
"postcss-preset-mantine": {},
|
||||||
|
"postcss-simple-vars": {
|
||||||
|
variables: {
|
||||||
|
"mantine-breakpoint-xs": "36em",
|
||||||
|
"mantine-breakpoint-sm": "48em",
|
||||||
|
"mantine-breakpoint-md": "62em",
|
||||||
|
"mantine-breakpoint-lg": "75em",
|
||||||
|
"mantine-breakpoint-xl": "88em",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
BIN
web/public/favicon/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 28 KiB |
BIN
web/public/favicon/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 164 KiB |
BIN
web/public/favicon/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 25 KiB |
BIN
web/public/favicon/favicon-16x16.png
Normal file
After Width: | Height: | Size: 799 B |
BIN
web/public/favicon/favicon-32x32.png
Normal file
After Width: | Height: | Size: 2.0 KiB |
BIN
web/public/favicon/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
19
web/public/favicon/site.webmanifest
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"name": "",
|
||||||
|
"short_name": "",
|
||||||
|
"icons": [
|
||||||
|
{
|
||||||
|
"src": "/favicon/android-chrome-192x192.png",
|
||||||
|
"sizes": "192x192",
|
||||||
|
"type": "image/png"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"src": "/favicon/android-chrome-512x512.png",
|
||||||
|
"sizes": "512x512",
|
||||||
|
"type": "image/png"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"theme_color": "#ffffff",
|
||||||
|
"background_color": "#ffffff",
|
||||||
|
"display": "standalone"
|
||||||
|
}
|
BIN
web/public/logo.png
Normal file
After Width: | Height: | Size: 243 KiB |
BIN
web/public/mockup.png
Normal file
After Width: | Height: | Size: 1.1 MiB |
29
web/remix.config.js
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
import { defineRoutes } from "@remix-run/dev/dist/config/routes.js";
|
||||||
|
|
||||||
|
/** @type {import('@remix-run/dev').AppConfig} */
|
||||||
|
export default {
|
||||||
|
postcss: true,
|
||||||
|
ignoredRouteFiles: ["**/.*"],
|
||||||
|
routes(defineRoutes) {
|
||||||
|
return defineRoutes((route) => {
|
||||||
|
route("/", "routes/_index.tsx", { index: true });
|
||||||
|
route("logbook", "routes/logbook/route.tsx", () => {
|
||||||
|
route("dashboard", "routes/logbook/dashboard.tsx");
|
||||||
|
route("admin", "routes/logbook/admin.tsx");
|
||||||
|
route("me", "routes/logbook/me.tsx");
|
||||||
|
route("aircraft", "routes/logbook/aircraft.tsx");
|
||||||
|
route("flights", "routes/logbook/flights/route.tsx", () => {
|
||||||
|
route("", "routes/logbook/flights/_index.tsx", { index: true });
|
||||||
|
route(":id", "routes/logbook/flights/$id.tsx");
|
||||||
|
route("new", "routes/logbook/flights/new.tsx");
|
||||||
|
route("edit/:id", "routes/logbook/flights/edit/$id.tsx");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
route("login", "routes/login.tsx");
|
||||||
|
});
|
||||||
|
},
|
||||||
|
// appDirectory: "app",
|
||||||
|
// assetsBuildDirectory: "public/build",
|
||||||
|
// publicPath: "/build/",
|
||||||
|
// serverBuildPath: "build/index.js",
|
||||||
|
};
|
2
web/remix.env.d.ts
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
/// <reference types="@remix-run/dev" />
|
||||||
|
/// <reference types="@remix-run/node" />
|
22
web/tsconfig.json
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"include": ["remix.env.d.ts", "**/*.ts", "**/*.tsx"],
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["DOM", "DOM.Iterable", "ES2022"],
|
||||||
|
"isolatedModules": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"target": "ES2022",
|
||||||
|
"strict": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./app/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
// Remix takes care of building everything in `remix build`.
|
||||||
|
"noEmit": true
|
||||||
|
}
|
||||||
|
}
|