···
1
+
import { css, html, LitElement } from "lit";
2
+
import { customElement, state } from "lit/decorators.js";
15
+
interface WaitlistEntry {
18
+
course_code: string;
19
+
course_name: string;
23
+
additional_info: string | null;
24
+
meeting_times: string | null;
28
+
@customElement("admin-classes")
29
+
export class AdminClasses extends LitElement {
30
+
@state() classes: Class[] = [];
31
+
@state() waitlist: WaitlistEntry[] = [];
32
+
@state() isLoading = true;
33
+
@state() error = "";
34
+
@state() searchTerm = "";
35
+
@state() showCreateModal = false;
36
+
@state() activeTab: "classes" | "waitlist" = "classes";
37
+
@state() approvingEntry: WaitlistEntry | null = null;
38
+
@state() meetingTimes: string[] = [""];
40
+
static override styles = css`
47
+
justify-content: space-between;
48
+
align-items: center;
49
+
margin-bottom: 1.5rem;
56
+
padding: 0.5rem 0.75rem;
57
+
border: 2px solid var(--secondary);
60
+
font-family: inherit;
61
+
background: var(--background);
67
+
border-color: var(--primary);
71
+
padding: 0.75rem 1.5rem;
72
+
background: var(--primary);
74
+
border: 2px solid var(--primary);
79
+
transition: all 0.2s;
80
+
font-family: inherit;
81
+
white-space: nowrap;
85
+
background: var(--gunmetal);
86
+
border-color: var(--gunmetal);
95
+
background: var(--background);
96
+
border: 2px solid var(--secondary);
99
+
transition: all 0.2s;
102
+
.class-card:hover {
103
+
border-color: var(--primary);
106
+
.class-card.archived {
108
+
border-style: dashed;
113
+
justify-content: space-between;
114
+
align-items: flex-start;
116
+
margin-bottom: 0.75rem;
124
+
font-size: 0.875rem;
126
+
color: var(--accent);
127
+
text-transform: uppercase;
131
+
font-size: 1.125rem;
134
+
color: var(--text);
140
+
font-size: 0.875rem;
141
+
color: var(--paynes-gray);
146
+
display: inline-block;
147
+
padding: 0.25rem 0.5rem;
148
+
border-radius: 4px;
149
+
font-size: 0.75rem;
151
+
text-transform: uppercase;
155
+
background: var(--secondary);
156
+
color: var(--text);
166
+
padding: 0.5rem 1rem;
168
+
border-radius: 6px;
169
+
font-size: 0.875rem;
172
+
transition: all 0.2s;
173
+
font-family: inherit;
177
+
background: transparent;
178
+
color: var(--paynes-gray);
179
+
border-color: var(--secondary);
182
+
.btn-archive:hover {
183
+
border-color: var(--paynes-gray);
187
+
background: transparent;
189
+
border-color: #dc2626;
192
+
.btn-delete:hover {
193
+
background: #dc2626;
199
+
cursor: not-allowed;
203
+
text-align: center;
204
+
padding: 3rem 2rem;
205
+
color: var(--paynes-gray);
209
+
text-align: center;
210
+
padding: 3rem 2rem;
211
+
color: var(--paynes-gray);
215
+
background: #fee2e2;
218
+
border-radius: 6px;
219
+
margin-bottom: 1rem;
225
+
margin-bottom: 2rem;
226
+
border-bottom: 2px solid var(--secondary);
230
+
padding: 0.75rem 1.5rem;
231
+
background: transparent;
234
+
color: var(--text);
238
+
font-family: inherit;
239
+
border-bottom: 2px solid transparent;
240
+
margin-bottom: -2px;
241
+
transition: all 0.2s;
245
+
color: var(--primary);
249
+
color: var(--primary);
250
+
border-bottom-color: var(--primary);
254
+
display: inline-block;
255
+
margin-left: 0.5rem;
256
+
padding: 0.125rem 0.5rem;
257
+
background: var(--accent);
259
+
border-radius: 12px;
260
+
font-size: 0.75rem;
270
+
background: rgba(0, 0, 0, 0.5);
272
+
align-items: center;
273
+
justify-content: center;
279
+
background: var(--background);
280
+
border: 2px solid var(--secondary);
281
+
border-radius: 12px;
290
+
margin: 0 0 1.5rem 0;
291
+
color: var(--text);
296
+
margin-bottom: 1rem;
299
+
.form-group label {
301
+
margin-bottom: 0.5rem;
303
+
color: var(--text);
304
+
font-size: 0.875rem;
307
+
.form-group input {
310
+
border: 2px solid var(--secondary);
311
+
border-radius: 6px;
313
+
font-family: inherit;
314
+
background: var(--background);
315
+
color: var(--text);
316
+
box-sizing: border-box;
319
+
.form-group input:focus {
321
+
border-color: var(--primary);
324
+
.meeting-times-list {
326
+
flex-direction: column;
330
+
.meeting-time-row {
333
+
align-items: center;
336
+
.meeting-time-row input {
342
+
background: transparent;
344
+
border: 2px solid #dc2626;
345
+
border-radius: 6px;
347
+
font-size: 0.875rem;
349
+
transition: all 0.2s;
352
+
.btn-remove:hover {
353
+
background: #dc2626;
358
+
margin-top: 0.5rem;
359
+
padding: 0.5rem 1rem;
360
+
background: transparent;
361
+
color: var(--primary);
362
+
border: 2px solid var(--primary);
363
+
border-radius: 6px;
365
+
font-size: 0.875rem;
367
+
transition: all 0.2s;
371
+
background: var(--primary);
378
+
justify-content: flex-end;
379
+
margin-top: 1.5rem;
383
+
padding: 0.75rem 1.5rem;
384
+
background: var(--primary);
386
+
border: 2px solid var(--primary);
387
+
border-radius: 6px;
391
+
transition: all 0.2s;
392
+
font-family: inherit;
395
+
.btn-submit:hover:not(:disabled) {
396
+
background: var(--gunmetal);
397
+
border-color: var(--gunmetal);
400
+
.btn-submit:disabled {
402
+
cursor: not-allowed;
406
+
padding: 0.75rem 1.5rem;
407
+
background: transparent;
408
+
color: var(--text);
409
+
border: 2px solid var(--secondary);
410
+
border-radius: 6px;
414
+
transition: all 0.2s;
415
+
font-family: inherit;
418
+
.btn-cancel:hover {
419
+
border-color: var(--primary);
420
+
color: var(--primary);
424
+
override async connectedCallback() {
425
+
super.connectedCallback();
426
+
await this.loadData();
429
+
private async loadData() {
430
+
this.isLoading = true;
434
+
const [classesRes, waitlistRes] = await Promise.all([
435
+
fetch("/api/admin/classes"),
436
+
fetch("/api/admin/waitlist"),
439
+
if (!classesRes.ok || !waitlistRes.ok) {
440
+
throw new Error("Failed to load data");
443
+
const classesData = await classesRes.json();
444
+
const waitlistData = await waitlistRes.json();
446
+
this.classes = classesData.classes || [];
447
+
this.waitlist = waitlistData.waitlist || [];
449
+
this.error = "Failed to load data. Please try again.";
451
+
this.isLoading = false;
455
+
private handleSearch(e: Event) {
456
+
this.searchTerm = (e.target as HTMLInputElement).value.toLowerCase();
459
+
private async handleToggleArchive(classId: string) {
461
+
const response = await fetch(`/api/classes/${classId}/archive`, {
465
+
if (!response.ok) {
466
+
throw new Error("Failed to update class");
469
+
await this.loadData();
471
+
this.error = "Failed to update class. Please try again.";
475
+
private async handleDelete(classId: string, courseName: string) {
478
+
`Are you sure you want to delete ${courseName}? This will remove all associated data and cannot be undone.`,
485
+
const response = await fetch(`/api/classes/${classId}`, {
489
+
if (!response.ok) {
490
+
throw new Error("Failed to delete class");
493
+
await this.loadData();
495
+
this.error = "Failed to delete class. Please try again.";
499
+
private handleCreateClass() {
500
+
this.showCreateModal = true;
503
+
private async handleDeleteWaitlist(id: string, courseCode: string) {
506
+
`Are you sure you want to delete this waitlist request for ${courseCode}?`,
513
+
const response = await fetch(`/api/admin/waitlist/${id}`, {
517
+
if (!response.ok) {
518
+
throw new Error("Failed to delete waitlist entry");
521
+
await this.loadData();
523
+
this.error = "Failed to delete waitlist entry. Please try again.";
527
+
private getFilteredClasses() {
528
+
if (!this.searchTerm) return this.classes;
530
+
return this.classes.filter((cls) => {
531
+
const searchStr = this.searchTerm;
533
+
cls.course_code.toLowerCase().includes(searchStr) ||
534
+
cls.name.toLowerCase().includes(searchStr) ||
535
+
cls.professor.toLowerCase().includes(searchStr)
540
+
override render() {
541
+
if (this.isLoading) {
542
+
return html`<div class="loading">Loading...</div>`;
545
+
const filteredClasses = this.getFilteredClasses();
548
+
${this.error ? html`<div class="error-message">${this.error}</div>` : ""}
552
+
class="tab ${this.activeTab === "classes" ? "active" : ""}"
553
+
@click=${() => { this.activeTab = "classes"; }}
558
+
class="tab ${this.activeTab === "waitlist" ? "active" : ""}"
559
+
@click=${() => { this.activeTab = "waitlist"; }}
562
+
${this.waitlist.length > 0 ? html`<span class="tab-badge">${this.waitlist.length}</span>` : ""}
567
+
this.activeTab === "classes"
568
+
? this.renderClasses(filteredClasses)
569
+
: this.renderWaitlist()
572
+
${this.approvingEntry ? this.renderApprovalModal() : ""}
576
+
private renderClasses(filteredClasses: Class[]) {
578
+
<div class="header">
582
+
placeholder="Search classes..."
583
+
@input=${this.handleSearch}
584
+
.value=${this.searchTerm}
586
+
<button class="create-btn" @click=${this.handleCreateClass}>
592
+
filteredClasses.length === 0
594
+
<div class="empty-state">
595
+
${this.searchTerm ? "No classes found matching your search" : "No classes yet"}
599
+
<div class="classes-grid">
600
+
${filteredClasses.map(
602
+
<div class="class-card ${cls.archived ? "archived" : ""}">
603
+
<div class="class-header">
604
+
<div class="class-info">
605
+
<div class="course-code">${cls.course_code}</div>
606
+
<div class="class-name">${cls.name}</div>
607
+
<div class="class-meta">
608
+
<span>👤 ${cls.professor}</span>
609
+
<span>📅 ${cls.semester} ${cls.year}</span>
610
+
${cls.archived ? html`<span class="badge archived">Archived</span>` : ""}
613
+
<div class="actions">
615
+
class="btn-archive"
616
+
@click=${() => this.handleToggleArchive(cls.id)}
618
+
${cls.archived ? "Unarchive" : "Archive"}
622
+
@click=${() => this.handleDelete(cls.id, cls.course_code)}
637
+
private renderWaitlist() {
640
+
this.waitlist.length === 0
642
+
<div class="empty-state">No waitlist requests yet</div>
645
+
<div class="classes-grid">
646
+
${this.waitlist.map(
648
+
<div class="class-card">
649
+
<div class="class-header">
650
+
<div class="class-info">
651
+
<div class="course-code">${entry.course_code}</div>
652
+
<div class="class-name">${entry.course_name}</div>
653
+
<div class="class-meta">
654
+
<span>👤 ${entry.professor}</span>
655
+
<span>📅 ${entry.semester} ${entry.year}</span>
658
+
entry.additional_info
660
+
<p style="margin-top: 0.75rem; font-size: 0.875rem; color: var(--paynes-gray);">
661
+
${entry.additional_info}
667
+
<div class="actions">
669
+
class="btn-archive"
670
+
@click=${() => this.handleApproveWaitlist(entry)}
672
+
Approve & Create Class
676
+
@click=${() => this.handleDeleteWaitlist(entry.id, entry.course_code)}
691
+
private handleApproveWaitlist(entry: WaitlistEntry) {
692
+
this.approvingEntry = entry;
694
+
// Parse meeting times from JSON if available, otherwise use empty array
695
+
if (entry.meeting_times) {
697
+
const parsed = JSON.parse(entry.meeting_times);
698
+
this.meetingTimes = Array.isArray(parsed) && parsed.length > 0 ? parsed : [""];
700
+
this.meetingTimes = [""];
703
+
this.meetingTimes = [""];
707
+
private addMeetingTime() {
708
+
this.meetingTimes = [...this.meetingTimes, ""];
711
+
private removeMeetingTime(index: number) {
712
+
this.meetingTimes = this.meetingTimes.filter((_, i) => i !== index);
715
+
private updateMeetingTime(index: number, value: string) {
716
+
this.meetingTimes = this.meetingTimes.map((time, i) =>
717
+
i === index ? value : time,
721
+
private cancelApproval() {
722
+
this.approvingEntry = null;
723
+
this.meetingTimes = [""];
726
+
private async submitApproval() {
727
+
if (!this.approvingEntry) return;
729
+
const entry = this.approvingEntry;
730
+
const times = this.meetingTimes.filter((t) => t.trim() !== "");
732
+
if (times.length === 0) {
733
+
this.error = "Please add at least one meeting time";
738
+
const response = await fetch("/api/classes", {
740
+
headers: { "Content-Type": "application/json" },
741
+
body: JSON.stringify({
742
+
course_code: entry.course_code,
743
+
name: entry.course_name,
744
+
professor: entry.professor,
745
+
semester: entry.semester,
747
+
meeting_times: times,
751
+
if (!response.ok) {
752
+
throw new Error("Failed to create class");
755
+
await fetch(`/api/admin/waitlist/${entry.id}`, {
759
+
await this.loadData();
761
+
this.activeTab = "classes";
762
+
this.approvingEntry = null;
763
+
this.meetingTimes = [""];
765
+
this.error = "Failed to approve waitlist entry. Please try again.";
769
+
private renderApprovalModal() {
770
+
if (!this.approvingEntry) return "";
772
+
const entry = this.approvingEntry;
775
+
<div class="modal-overlay" @click=${this.cancelApproval}>
776
+
<div class="modal" @click=${(e: Event) => e.stopPropagation()}>
777
+
<h2 class="modal-title">Add Meeting Times</h2>
779
+
<p style="margin-bottom: 1.5rem; color: var(--paynes-gray);">
780
+
Creating class: <strong>${entry.course_code} - ${entry.course_name}</strong>
783
+
<div class="form-group">
784
+
<label>Meeting Times</label>
785
+
<div class="meeting-times-list">
786
+
${this.meetingTimes.map(
787
+
(time, index) => html`
788
+
<div class="meeting-time-row">
791
+
placeholder="e.g., Monday Lecture, Wednesday Lab"
793
+
@input=${(e: Event) =>
794
+
this.updateMeetingTime(
796
+
(e.target as HTMLInputElement).value,
800
+
this.meetingTimes.length > 1
804
+
@click=${() => this.removeMeetingTime(index)}
814
+
<button class="btn-add" @click=${this.addMeetingTime}>
820
+
<div class="modal-actions">
821
+
<button class="btn-cancel" @click=${this.cancelApproval}>
826
+
@click=${this.submitApproval}
827
+
?disabled=${this.meetingTimes.every((t) => t.trim() === "")}