Add reservation cancellation feature and log

retrieval endpoint
This commit is contained in:
Jakob Kordež 2023-11-14 10:28:52 +01:00
parent 3c2708b1dc
commit 6480457b8c
8 changed files with 173 additions and 58 deletions

View File

@ -20,6 +20,8 @@ import { FileInterceptor } from '@nestjs/platform-express';
import { MongoIdPipe } from 'src/pipes/mongo-id.pipe';
import { AdifValidationPipe } from 'src/pipes/adif-validation.pipe';
import { SimpleAdif } from 'adif-parser-ts';
import { Role } from 'src/enums/role.enum';
import { Roles } from 'src/decorators/roles.decorator';
@Controller('reservations')
export class ReservationsController {
@ -32,6 +34,14 @@ export class ReservationsController {
return res;
}
@Get(':id/log')
async getLog(@Param('id', MongoIdPipe) id: string): Promise<string> {
const res = await this.reservationsService.findOne(id);
if (!res) throw new NotFoundException('Reservation not found');
if (!res.adiFile) throw new NotFoundException('Log not found');
return res.adiFile;
}
@Post(':id/log')
@UseInterceptors(FileInterceptor('file'))
async uploadLog(
@ -107,8 +117,8 @@ export class ReservationsController {
return this.reservationsService.update(id, updateReservationDto);
}
@Delete(':id')
async remove(
@Patch(':id/cancel')
async cancel(
@RequestUser() user: UserTokenData,
@Param('id') id: string,
): Promise<Reservation> {
@ -117,6 +127,15 @@ export class ReservationsController {
if (res.user !== user.id)
throw new ForbiddenException('Reservation not owned by user');
return this.reservationsService.remove(id);
return this.reservationsService.cancel(id);
}
@Roles(Role.Admin)
@Delete(':id')
async remove(@Param('id') id: string): Promise<Reservation> {
const res = await this.reservationsService.remove(id);
if (!res) throw new NotFoundException('Reservation not found');
return res;
}
}

View File

@ -40,7 +40,7 @@ export class ReservationsService {
}: ReservationFilter): Promise<Reservation[]> {
return this.reservationModel
.find({
$nor: [{ isDeleted: true }],
$nor: [{ isDeleted: true }, { isCancelled: true }],
$and: [
userId ? { user: userId } : {},
eventId ? { event: eventId } : {},
@ -78,6 +78,12 @@ export class ReservationsService {
.exec();
}
cancel(id: string): Promise<Reservation> {
return this.reservationModel
.findByIdAndUpdate(id, { $set: { isCancelled: true } }, { new: true })
.exec();
}
remove(id: string): Promise<Reservation> {
return this.reservationModel
.findByIdAndUpdate(id, { $set: { isDeleted: true } }, { new: true })

View File

@ -61,6 +61,11 @@ export class Reservation {
})
logSummary: LogSummary;
@Prop({
default: false,
})
isCancelled: boolean;
@Prop({
required: true,
default: Date.now,

View File

@ -278,6 +278,13 @@ export const apiFunctions = {
)
).data;
},
getLog: async (reservationId: string) => {
return (
await api.get<string>(`/reservations/${reservationId}/log`, {
headers: await getAuthHeader(),
})
).data;
},
};
const isoDateFormat = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d*)?Z?$/;

View File

@ -2,6 +2,15 @@
import { THEME_DARK, useThemeState } from '@/state/theme-state';
import { useEffect, useState } from 'react';
import { Allerta, Inter } from 'next/font/google';
const callsignFont = Allerta({
subsets: ['latin'],
variable: '--callsign-font',
weight: '400',
});
const inter = Inter({ subsets: ['latin'] });
export function LayoutComponent({ children }: { children: React.ReactNode }) {
const [theme, setTheme] = useState<string>(THEME_DARK);
@ -12,8 +21,12 @@ export function LayoutComponent({ children }: { children: React.ReactNode }) {
}, []);
return (
<div data-theme={theme} className="min-h-screen">
{children}
</div>
<html lang="sl" data-theme={theme}>
<body
className={`${inter.className} ${callsignFont.variable} min-h-screen dark:[color-scheme:dark]`}
>
{children}
</body>
</html>
);
}

View File

@ -2,7 +2,6 @@ import './globals.css';
import type { Metadata } from 'next';
import { LayoutComponent } from './layout-component';
import { Header } from '@/components/header';
import { Allerta, Inter } from 'next/font/google';
export const metadata: Metadata = {
title: {
@ -11,29 +10,15 @@ export const metadata: Metadata = {
},
};
const callsignFont = Allerta({
subsets: ['latin'],
variable: '--callsign-font',
weight: '400',
});
const inter = Inter({ subsets: ['latin'] });
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="sl">
<body
className={`${inter.className} ${callsignFont.variable} dark:[color-scheme:dark]`}
>
<LayoutComponent>
<Header />
<main>{children}</main>
</LayoutComponent>
</body>
</html>
<LayoutComponent>
<Header />
<main>{children}</main>
</LayoutComponent>
);
}

View File

@ -46,7 +46,7 @@ export default function Login({ searchParams: { redirect } }: LoginProps) {
return (
<div className="container py-10">
<form
className="mx-auto flex max-w-2xl flex-col gap-4 rounded-xl bg-gray-100 p-10 shadow-lg dark:bg-gray-800"
className="mx-auto flex max-w-2xl flex-col gap-4 rounded-xl bg-base-200 p-10 shadow-lg dark:bg-neutral-900"
onSubmit={(e) => {
e.preventDefault();
login();

View File

@ -7,11 +7,14 @@ import { Reservation } from '@/interfaces/reservation.interface';
import { useUserState } from '@/state/user-state';
import { getUTCString } from '@/util/date.util';
import {
faArrowLeft,
faDownload,
faFileCircleCheck,
faFileCircleExclamation,
} from '@fortawesome/free-solid-svg-icons';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { useState } from 'react';
import Link from 'next/link';
import { useEffect, useRef, useState } from 'react';
interface ReservationComponentProps {
reservation: Reservation;
@ -25,6 +28,11 @@ export function ReservationComponent({
return (
<div className="flex flex-col gap-10">
<div className="flex flex-col gap-4">
<Link href={`/event/${event._id}`} className="link">
<FontAwesomeIcon icon={faArrowLeft} width={32} height={32} />
Nazaj na dogodek
</Link>
<h1 className="font-callsign text-3xl">{event.callsign}</h1>
<div className="flex flex-col gap-1">
@ -59,6 +67,10 @@ export function ReservationComponent({
<span>
Dnevnik {reservation.logSummary ? 'uspešno' : 'še ni'} oddan
</span>
{reservation.logSummary && (
<DownloadButton reservation={reservation} event={event} />
)}
</div>
{reservation.logSummary && (
@ -72,40 +84,108 @@ export function ReservationComponent({
);
}
function DownloadButton({
reservation,
event,
}: {
reservation: Reservation;
event: Event;
}) {
const aRef = useRef<HTMLAnchorElement>(null);
const [url, setUrl] = useState<string>();
useEffect(() => {
return () => {
if (url) window.URL.revokeObjectURL(url);
};
});
function download() {
if (url) {
aRef.current?.click();
return;
}
apiFunctions
.getLog(reservation._id)
.then((res) => {
const url = window.URL.createObjectURL(new Blob([res]));
setUrl(url);
aRef.current?.setAttribute('href', url);
aRef.current?.click();
})
.catch((e) => {
console.error(e);
});
}
const children = (
<>
<FontAwesomeIcon icon={faDownload} width={16} height={16} />
Prenesi
</>
);
return (
<>
<button
className={`btn btn-sm ${url ? 'hidden' : ''}`}
onClick={() => download()}
>
{children}
</button>
<a
ref={aRef}
href={''}
className={`btn btn-sm ${url ? '' : 'hidden'}`}
download={`${event.callsign}_${reservation._id}.adi`}
>
{children}
</a>
</>
);
}
function LogSummaryC({ logSummary }: { logSummary: LogSummary }) {
return (
<div className="max-h-72 overflow-y-auto rounded-lg bg-gray-100 p-5 dark:bg-gray-800">
<div className="mb-3 text-xl font-medium">Poročilo oddanega dnevnika</div>
<div className="rounded-lg bg-base-200 p-5 pr-2">
<div className="max-h-72 overflow-y-auto pr-2">
<div className="mb-3 text-xl font-medium">
Poročilo oddanega dnevnika
</div>
<table className="table table-sm">
<tbody>
<tr>
<td>Število zvez</td>
<td>{logSummary.qso_count}</td>
</tr>
<tr>
<td>Frekvenčni pasovi</td>
<td>{logSummary.bands.join(', ').toLocaleLowerCase()}</td>
</tr>
<tr>
<td>Načini dela</td>
<td>{logSummary.modes.join(', ')}</td>
</tr>
<tr>
<td>Čas dela</td>
<td>
{getUTCString(logSummary.first_qso_time)} &rarr;{' '}
{getUTCString(logSummary.last_qso_time)}
</td>
</tr>
</tbody>
</table>
<table className="table table-sm">
<tbody>
<tr>
<td>Število zvez</td>
<td>{logSummary.qso_count}</td>
</tr>
<tr>
<td>Frekvenčni pasovi</td>
<td>{logSummary.bands.join(', ').toLocaleLowerCase()}</td>
</tr>
<tr>
<td>Načini dela</td>
<td>{logSummary.modes.join(', ')}</td>
</tr>
<tr>
<td>Čas dela</td>
<td>
{getUTCString(logSummary.first_qso_time)} &rarr;{' '}
{getUTCString(logSummary.last_qso_time)}
</td>
</tr>
</tbody>
</table>
<div className="text-orange-500 dark:text-warning">
{(logSummary.warnings?.length ?? 0) > 0 && (
<div className="mt-3">Opozorila:</div>
)}
{logSummary.warnings?.map((warning, i) => <div key={i}>{warning}</div>)}
<div className="text-orange-500 dark:text-warning">
{(logSummary.warnings?.length ?? 0) > 0 && (
<div className="mt-3">Opozorila:</div>
)}
{logSummary.warnings?.map((warning, i) => (
<div key={i}>{warning}</div>
))}
</div>
</div>
</div>
);