Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/app/(main)/lag/[id]/_components/event-registration.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ export default function EventRegistration({
utils.registration.getMyRegistration.invalidate({ eventId });
utils.registration.getCounts.invalidate({ eventId });
utils.registration.getAllByEvent.invalidate({ eventId });
utils.event.getUnanswered.invalidate();
router.refresh();
},
onError: (error) => {
Expand Down
2 changes: 1 addition & 1 deletion src/app/admin/brukere/_components/edit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ export default function EditRole({ userId, isAdmin }: EditRoleProps) {
className="order-1 after:absolute after:inset-0"
/>
<div className="grid grow gap-1">
<Label htmlFor="true">Administrator</Label>
<Label htmlFor="true">Superadministrator</Label>
<p
id="true-description"
className="text-muted-foreground text-xs"
Expand Down
2 changes: 2 additions & 0 deletions src/components/navigation/top-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { authClient } from "~/lib/auth-client";
import TihldeLogo from "../logo";
import { Button } from "../ui/button";
import LoginForm from "./sign-in";
import UnansweredEventsDropdown from "./unanswered-events-dropdown";
import UserAvatar from "./user-avatar";

const navigationItems = [
Expand Down Expand Up @@ -76,6 +77,7 @@ const Navbar = () => {
{isPending && <Loader2 className="h-4 w-4 animate-spin" />}
{!session?.user && !isPending && <LoginForm />}
{session?.user && <UserAvatar />}
{session?.user && <UnansweredEventsDropdown />}
<Button
size="icon"
variant="ghost"
Expand Down
120 changes: 120 additions & 0 deletions src/components/navigation/unanswered-events-dropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
"use client";

import type { TeamEvent } from "@prisma/client";
import { format } from "date-fns";
import { nb } from "date-fns/locale";
import { ListChecks } from "lucide-react";
import { useState } from "react";
import { EventDialog } from "~/components/event-calendar/event-dialog";
import { Button } from "~/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { api } from "~/trpc/react";

const getEventTypeLabel = (type: string): string => {
switch (type) {
case "MATCH":
return "Kamp";
case "TRAINING":
return "Trening";
case "SOCIAL":
return "Sosialt";
case "OTHER":
return "Annet";
default:
return type;
}
};

export default function UnansweredEventsDropdown() {
const { data: unansweredEvents } = api.event.getUnanswered.useQuery();
const [selectedEvent, setSelectedEvent] = useState<TeamEvent | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);

const handleEventClick = (event: TeamEvent & { team?: { name: string } }) => {
setSelectedEvent(event);
setIsDialogOpen(true);
};

const unansweredCount = unansweredEvents?.length || 0;

return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant="ghost"
className="relative h-8 w-8 sm:h-10 sm:w-10"
>
<ListChecks className="h-[1rem] w-[1rem] sm:h-[1.2rem] sm:w-[1.2rem]" />
{unansweredCount > 0 && (
<span className="absolute top-0 right-0 flex h-4 w-4 items-center justify-center rounded-full bg-red-500 text-[10px] text-white">
{unansweredCount > 9 ? "9+" : unansweredCount}
</span>
)}
<span className="sr-only">Ubesvarte events</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<DropdownMenuLabel>Ubesvarte events</DropdownMenuLabel>
<DropdownMenuSeparator />
{!unansweredEvents || unansweredEvents.length === 0 ? (
<div className="p-4 text-center text-muted-foreground text-sm">
Ingen ubesvarte events
</div>
) : (
<div className="max-h-[400px] overflow-y-auto">
{unansweredEvents.map(
(event: TeamEvent & { team?: { name: string } }) => (
<DropdownMenuItem
key={event.id}
onClick={() => handleEventClick(event)}
className="cursor-pointer flex-col items-start gap-1 p-3"
>
<div className="flex w-full items-start justify-between gap-2">
<div className="flex-1">
<div className="font-medium text-sm">{event.name}</div>
<div className="text-muted-foreground text-xs">
{"team" in event &&
event.team &&
typeof event.team === "object" &&
"name" in event.team
? (event.team as { name: string }).name
: ""}
</div>
</div>
<span className="whitespace-nowrap text-muted-foreground text-xs">
{getEventTypeLabel(event.eventType)}
</span>
</div>
<div className="text-muted-foreground text-xs">
{format(new Date(event.startAt), "EEE d. MMM HH:mm", {
locale: nb,
})}
</div>
</DropdownMenuItem>
),
)}
</div>
)}
</DropdownMenuContent>
</DropdownMenu>

<EventDialog
event={selectedEvent}
isOpen={isDialogOpen}
onClose={() => {
setIsDialogOpen(false);
setSelectedEvent(null);
}}
/>
</>
);
}
57 changes: 57 additions & 0 deletions src/server/api/event/controller/get-unanswered.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import type { TeamEvent } from "@prisma/client";
import { type Controller, authorizedProcedure } from "~/server/api/trpc";
import { db } from "~/server/db";

type UnansweredEvent = TeamEvent & {
team: {
name: string;
};
};

const handler: Controller<void, UnansweredEvent[]> = async ({ ctx }) => {
const userId = ctx.user.id;

// Get all events from teams the user is a member of
// where the event is in the future
// and the user hasn't registered (or registration is null)
const events = await db.teamEvent.findMany({
where: {
team: {
members: {
some: {
userId: userId,
},
},
},
startAt: {
gte: new Date(),
},
OR: [
// No registration exists
{
registrations: {
none: {
userId: userId,
},
},
},
// Registration exists but is neither ATTENDING nor NOT_ATTENDING
// This shouldn't happen with current schema, but keeping for safety
],
},
include: {
team: {
select: {
name: true,
},
},
},
orderBy: {
startAt: "asc",
},
});

return events;
};

export default authorizedProcedure.query(handler);
2 changes: 2 additions & 0 deletions src/server/api/event/router.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import { createTRPCRouter } from "../trpc";
import create from "./controller/create";
import deleteEvent from "./controller/delete";
import getUnanswered from "./controller/get-unanswered";
import update from "./controller/update";
import { registrationRouter } from "./registration/router";

export const eventRouter = createTRPCRouter({
create,
update,
delete: deleteEvent,
getUnanswered,
registration: registrationRouter,
});