mirror of
https://github.com/jakobkordez/ham-reserve.git
synced 2025-05-31 08:49:06 +00:00
Add reservation cancellation feature and log
retrieval endpoint
This commit is contained in:
parent
3c2708b1dc
commit
6480457b8c
@ -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;
|
||||
}
|
||||
}
|
||||
|
@ -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 })
|
||||
|
@ -61,6 +61,11 @@ export class Reservation {
|
||||
})
|
||||
logSummary: LogSummary;
|
||||
|
||||
@Prop({
|
||||
default: false,
|
||||
})
|
||||
isCancelled: boolean;
|
||||
|
||||
@Prop({
|
||||
required: true,
|
||||
default: Date.now,
|
||||
|
@ -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?$/;
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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)} →{' '}
|
||||
{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)} →{' '}
|
||||
{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>
|
||||
);
|
||||
|
Loading…
x
Reference in New Issue
Block a user