Restructure routes, scroll selected flight into view on reload
This commit is contained in:
13
web/app/routes/logbook/admin.tsx
Normal file
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
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
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
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
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
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>
|
||||
);
|
||||
}
|
332
web/app/routes/logbook/flights/flights-list.tsx
Normal file
332
web/app/routes/logbook/flights/flights-list.tsx
Normal 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 };
|
69
web/app/routes/logbook/flights/new.tsx
Normal file
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
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
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>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user