Restructure routes, scroll selected flight into view on reload

This commit is contained in:
april
2024-01-18 13:40:51 -06:00
parent b0f90c3959
commit 6a0b455edb
14 changed files with 82 additions and 9 deletions

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

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

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

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

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

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

View File

@@ -0,0 +1,332 @@
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,
page,
}: {
flights: UseQueryResult<{
[year: string]: {
[month: string]: { [day: string]: FlightConciseSchema[] };
};
}>;
page: string;
}) {
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={page === 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 location = useLocation();
const page = location.pathname.split("/")[3];
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} page={page} />
</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 };

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

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

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