Skip to content

Commit ac76fae

Browse files
committed
2 parents 3cc13a0 + dfd4068 commit ac76fae

File tree

6 files changed

+1302
-1043
lines changed

6 files changed

+1302
-1043
lines changed

README.md

Lines changed: 6 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,61 +1,10 @@
1+
# Event Manager
12

2-
# Eventmanager
3-
4-
Der **Eventmanager** ist eine zentrale Plattform zur Verwaltung von **Controlleranmeldungen** und zur Erstellung sowie Veröffentlichung von **Besetzungsplänen** an die angemeldeten Controller.
5-
6-
Er vereinfacht interne Abläufe im Zusammenhang mit Events, automatisiert viele organisatorische Schritte, wie export der Anmeldungen in das Google Sheets, in dem der Besetzungsplan erstellt dann werden kann.
7-
8-
## Features
9-
10-
- **Multi-FIR Support**: Unterstützt mehrere FIR-Teams (EDMM, EDGG, EDWW) mit individuellen Export-Layouts
11-
- **FIR-spezifische Google Sheets**: Jede FIR kann ihre Signups in ein eigenes Google Sheet exportieren
12-
- **Konfigurierbare Export-Layouts**: Anpassbare Layouts für verschiedene Event-Teams
13-
- **Automatisierter Export**: Direkte Synchronisation von Anmeldungen zu Google Sheets
14-
15-
16-
## Running the Application
17-
18-
Development:
19-
20-
1. Run `npm install`
21-
2. Copy the `.env.example` to `.env`
22-
3. Edit the values stored in the `.env` file
23-
- Configure Google Sheets credentials
24-
- (Optional) Set FIR-specific sheet IDs: `GOOGLE_SHEET_ID_EDMM`, `GOOGLE_SHEET_ID_EDGG`, `GOOGLE_SHEET_ID_EDWW`
25-
4. **Choose your database:**
26-
- **Option A (Easy - SQLite):** No MySQL installation required!
27-
```bash
28-
# In .env, set:
29-
USE_TEST_DB=true
30-
DATABASE_URL=file:./dev.db
31-
32-
# Then run:
33-
npx prisma generate --schema=prisma/schema.sqlite.prisma
34-
npx prisma db push --schema=prisma/schema.sqlite.prisma
35-
npx tsx prisma/seed.ts # Optional: add initial data
36-
```
37-
- **Option B (MySQL/MariaDB):**
38-
```bash
39-
# In .env, configure:
40-
USE_TEST_DB=false
41-
DATABASE_URL=mysql://user:password@localhost:3306/dbname
42-
DB_HOST=localhost
43-
DB_PORT=3306
44-
DB_USER=user
45-
DB_PASSWORD=password
46-
DB_NAME=dbname
47-
48-
# Then run:
49-
npx prisma migrate dev
50-
```
51-
- See [SQLite Test Database Guide](docs/SQLITE_TEST_DATABASE.md) for more details
52-
5. Run `npm run dev`
53-
54-
Alternatively, you can build the project using Docker and the `docker-compose.yml`.
55-
Note that you will be required to add the environment variables to your development environment.
3+
Application to manage events in VATGER.
4+
It provides controller signups, roster planning, and tools to organize ATC staffing for events.
565

576
## Contact
587

59-
- Yannik Schäffler (1649341)
60-
- [@yschaffler](https://github.com/yschaffler)
61-
8+
| Name | Responsible for | Contact |
9+
| :------------------: | :-------------: | :---------------------------------------------: |
10+
| Yannik S. - 1649341 | * | events@vatger.de |

app/api/weeklys/[id]/route.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export async function GET(
1717
try {
1818
const { id } = await params;
1919

20+
const yesterday = addDays(new Date(), -1);
21+
2022
const config = await prisma.weeklyEventConfiguration.findUnique({
2123
where: {
2224
id: Number(id),
@@ -32,7 +34,7 @@ export async function GET(
3234
occurrences: {
3335
where: {
3436
date: {
35-
gte: new Date(addDays( new Date(), -1)),
37+
gte: yesterday,
3638
},
3739
},
3840
orderBy: {
@@ -50,9 +52,24 @@ export async function GET(
5052
);
5153
}
5254

55+
// Fetch past occurrences separately (most recent first, last 20)
56+
const pastOccurrences = await prisma.weeklyEventOccurrence.findMany({
57+
where: {
58+
configId: Number(id),
59+
date: {
60+
lt: yesterday,
61+
},
62+
},
63+
orderBy: {
64+
date: "desc",
65+
},
66+
take: 15,
67+
});
68+
5369
// Parse JSON fields
5470
const response = {
5571
...config,
72+
pastOccurrences,
5673
airports: config.airports
5774
? typeof config.airports === "string"
5875
? JSON.parse(config.airports)

app/page.tsx

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ export default function EventsPage() {
4040
const [events, setEvents] = useState<Event[]>([]);
4141
const [weeklys, setWeeklys] = useState<WeeklyConfig[]>([]);
4242
const [searchQuery, setSearchQuery] = useState("");
43+
const [archiveSearchQuery, setArchiveSearchQuery] = useState("");
4344
const [selectedFIR, setSelectedFIR] = useState(session?.user.fir || "all");
4445
const [showArchived, setShowArchived] = useState(false);
4546
const [viewMode, setViewMode] = useState<"events" | "weeklys">("events");
@@ -144,6 +145,18 @@ export default function EventsPage() {
144145
return { signedUpEvents, openEvents, archivedEvents, firOverviewEvents };
145146
}, [events, searchQuery, selectedFIR]);
146147

148+
// Filtered archive events based on archive search query
149+
const filteredArchivedEvents = useMemo(() => {
150+
if (archiveSearchQuery === "") return archivedEvents;
151+
const q = archiveSearchQuery.toLowerCase();
152+
return archivedEvents.filter((e: Event) =>
153+
e.name.toLowerCase().includes(q) ||
154+
(Array.isArray(e.airports) ? e.airports : [e.airports])
155+
.some((a: string) => a.toLowerCase().includes(q)) ||
156+
e.firCode.toLowerCase().includes(q)
157+
);
158+
}, [archivedEvents, archiveSearchQuery]);
159+
147160
// Filter weeklys by FIR and search
148161
const filteredWeeklys = useMemo(() => {
149162
return weeklys.filter(weekly => {
@@ -441,7 +454,10 @@ export default function EventsPage() {
441454
</div>
442455
<Button
443456
variant="outline"
444-
onClick={() => setShowArchived(!showArchived)}
457+
onClick={() => {
458+
setShowArchived(!showArchived);
459+
if (showArchived) setArchiveSearchQuery("");
460+
}}
445461
className="flex items-center gap-2"
446462
>
447463
{showArchived ? (
@@ -460,9 +476,23 @@ export default function EventsPage() {
460476

461477
{showArchived && (
462478
<>
463-
{archivedEvents.length > 0 ? (
479+
{/* Archive Search */}
480+
<div className="mb-5">
481+
<div className="relative max-w-sm">
482+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400 w-4 h-4" />
483+
<Input
484+
type="text"
485+
placeholder="Im Archiv suchen..."
486+
value={archiveSearchQuery}
487+
onChange={(e) => setArchiveSearchQuery(e.target.value)}
488+
className="pl-10 pr-4 py-2 w-full"
489+
/>
490+
</div>
491+
</div>
492+
493+
{filteredArchivedEvents.length > 0 ? (
464494
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6 opacity-60">
465-
{archivedEvents.map(event => (
495+
{filteredArchivedEvents.map(event => (
466496
<EventCard
467497
key={`archived-${event.id}`}
468498
event={event}
@@ -474,10 +504,13 @@ export default function EventsPage() {
474504
<div className="text-center py-8 bg-secondary rounded-lg border border-dashed border-gray-300">
475505
<FolderArchive className="w-12 h-12 text-gray-400 mx-auto mb-4" />
476506
<h3 className="text-base font-medium text-gray-400 mb-1">
477-
Keine vergangenen Events
507+
{archiveSearchQuery ? "Keine Ergebnisse" : "Keine vergangenen Events"}
478508
</h3>
479509
<p className="text-gray-400 text-sm">
480-
Es sind noch keine Events im Archiv vorhanden.
510+
{archiveSearchQuery
511+
? "Keine archivierten Events entsprechen deiner Suche."
512+
: "Es sind noch keine Events im Archiv vorhanden."
513+
}
481514
</p>
482515
</div>
483516
)}

app/weeklys/[id]/page.tsx

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,14 @@ import {
2020
Loader2,
2121
AlertCircle,
2222
ChevronRight,
23+
ChevronUp,
24+
ChevronDown,
2325
CalendarDays,
2426
Timer,
2527
Repeat,
2628
Plane,
2729
Check,
30+
History,
2831
} from "lucide-react";
2932
import { format, isPast, isBefore, isAfter } from "date-fns";
3033
import { de } from "date-fns/locale";
@@ -65,6 +68,7 @@ interface WeeklyConfig {
6568
signupDeadlineHours?: number;
6669
enabled: boolean;
6770
occurrences: WeeklyOccurrence[];
71+
pastOccurrences: WeeklyOccurrence[];
6872
}
6973

7074
const WEEKDAYS = [
@@ -92,6 +96,7 @@ export default function WeeklyDetailPage() {
9296
const [config, setConfig] = useState<WeeklyConfig | null>(null);
9397
const [loading, setLoading] = useState(true);
9498
const [error, setError] = useState("");
99+
const [showPastOccurrences, setShowPastOccurrences] = useState(false);
95100

96101
useEffect(() => {
97102
if (params.id) {
@@ -170,6 +175,26 @@ export default function WeeklyDetailPage() {
170175

171176
return result.slice(0, 10);
172177
};
178+
179+
/**
180+
* Generates past calendar weeks (already occurred occurrences),
181+
* sorted most-recent first.
182+
*/
183+
const generatePastCalendarWeeks = (): CalendarWeek[] => {
184+
if (!config || !config.pastOccurrences || config.pastOccurrences.length === 0) return [];
185+
186+
const toLocalDate = (dateStr: string): Date => {
187+
const d = new Date(dateStr);
188+
return new Date(d.getFullYear(), d.getMonth(), d.getDate());
189+
};
190+
191+
// Already sorted most-recent first by the API
192+
return config.pastOccurrences.map((occ) => ({
193+
type: "occurrence" as const,
194+
date: toLocalDate(occ.date),
195+
occurrence: occ,
196+
}));
197+
};
173198

174199

175200
const isSignupOpen = (occurrence: WeeklyOccurrence): boolean => {
@@ -566,6 +591,97 @@ export default function WeeklyDetailPage() {
566591
);
567592
})()}
568593
</div>
594+
{/* Past Occurrences Archive */}
595+
{(() => {
596+
const pastWeeks = generatePastCalendarWeeks();
597+
if (pastWeeks.length === 0) return null;
598+
599+
return (
600+
<div className="space-y-3">
601+
<button
602+
onClick={() => setShowPastOccurrences((v) => !v)}
603+
className="w-full flex items-center justify-center gap-2 py-2 px-4 rounded-lg border border-dashed text-sm text-muted-foreground hover:text-foreground hover:border-muted-foreground/50 transition-colors"
604+
>
605+
{showPastOccurrences ? (
606+
<>
607+
<ChevronUp className="h-4 w-4" />
608+
Vergangene Termine ausblenden
609+
</>
610+
) : (
611+
<>
612+
<ChevronDown className="h-4 w-4" />
613+
<History className="h-4 w-4" />
614+
{pastWeeks.length} vergangene{pastWeeks.length === 1 ? " Termin" : " Termine"} anzeigen
615+
</>
616+
)}
617+
</button>
618+
619+
{showPastOccurrences && (
620+
<div className="space-y-2">
621+
<div className="flex items-center gap-2 mb-1">
622+
<History className="h-4 w-4 text-muted-foreground" />
623+
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wider">
624+
Vergangene Termine
625+
</h3>
626+
</div>
627+
{pastWeeks.map((week) => {
628+
const occDate = week.date;
629+
const occurrence = week.occurrence!;
630+
const dayOfMonth = format(occDate, "dd");
631+
const month = MONTHS[occDate.getMonth()];
632+
const weekday = WEEKDAYS[occDate.getDay()];
633+
const formattedDate = format(occDate, "dd.MM.yyyy", { locale: de });
634+
635+
return (
636+
<Link
637+
key={occurrence.id}
638+
href={`/weeklys/${config!.id}/occurrences/${occurrence.id}`}
639+
className="block group"
640+
>
641+
<div className="flex items-stretch border rounded-lg overflow-hidden opacity-70 hover:opacity-90 transition-all hover:shadow-sm">
642+
{/* Datum-Block */}
643+
<div className="w-24 flex flex-col items-center justify-center py-3 border-r bg-muted/20">
644+
<span className="text-2xl font-bold leading-none text-muted-foreground">{dayOfMonth}</span>
645+
<span className="text-xs font-medium uppercase tracking-wider mt-1 text-muted-foreground">{month}</span>
646+
<span className="text-[10px] text-muted-foreground mt-0.5">{weekday}</span>
647+
</div>
648+
649+
{/* Content */}
650+
<div className="flex-1 p-4">
651+
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
652+
<div>
653+
<h3 className="font-semibold text-base text-muted-foreground">
654+
{formattedDate}
655+
</h3>
656+
{config!.startTime && config!.endTime && (
657+
<div className="flex items-center gap-1 text-sm text-muted-foreground">
658+
<Clock className="h-3.5 w-3.5" />
659+
<span>{config!.startTime} - {config!.endTime} Uhr</span>
660+
</div>
661+
)}
662+
</div>
663+
<div className="flex items-center gap-3">
664+
{occurrence.rosterPublished && (
665+
<Badge variant="secondary" className="text-xs">
666+
Roster veröffentlicht
667+
</Badge>
668+
)}
669+
<Button variant="ghost" size="sm" className="h-8 px-3 text-sm gap-1">
670+
Details
671+
<ChevronRight className="h-4 w-4" />
672+
</Button>
673+
</div>
674+
</div>
675+
</div>
676+
</div>
677+
</Link>
678+
);
679+
})}
680+
</div>
681+
)}
682+
</div>
683+
);
684+
})()}
569685
</div>
570686
);
571687
}

config/discordNotifications.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ export function resolveDiscordNotification(
9898
// 1. Weekly-specific config
9999
if (weeklyConfigId != null) {
100100
const specific = DISCORD_NOTIFICATIONS.find(
101-
(e) => e.firCode === firCode && e.notificationType === type && e.weeklyConfigId === weeklyConfigId
101+
(e) => e.firCode === firCode && e.notificationType === type && (e.weeklyConfigId === undefined || e.weeklyConfigId === weeklyConfigId)
102102
);
103103
if (specific) return { channelId: specific.channelId, roleId: specific.roleId };
104104
}

0 commit comments

Comments
 (0)