馃 distributed transcription service
thistle.dunkirk.sh
1import { css, html, LitElement } from "lit";
2import { customElement, state } from "lit/decorators.js";
3import type { MeetingTime } from "./meeting-time-picker";
4import "./meeting-time-picker";
5
6interface Class {
7 id: string;
8 course_code: string;
9 name: string;
10 professor: string;
11 semester: string;
12 year: number;
13 archived: boolean;
14 created_at: number;
15 student_count?: number;
16 transcript_count?: number;
17}
18
19interface WaitlistEntry {
20 id: string;
21 user_id: number;
22 course_code: string;
23 course_name: string;
24 professor: string;
25 semester: string;
26 year: number;
27 additional_info: string | null;
28 meeting_times: string | null;
29 created_at: number;
30}
31
32@customElement("admin-classes")
33export class AdminClasses extends LitElement {
34 @state() classes: Class[] = [];
35 @state() waitlist: WaitlistEntry[] = [];
36 @state() isLoading = true;
37 @state() error = "";
38 @state() searchTerm = "";
39
40 @state() activeTab: "classes" | "waitlist" = "classes";
41 @state() approvingEntry: WaitlistEntry | null = null;
42 @state() showModal = false;
43 @state() showClassSettingsModal = false;
44 @state() editingClassId: string | null = null;
45 @state() editingClassInfo: Class | null = null;
46 @state() editingClassSections: { id: string; section_number: string }[] = [];
47 @state() newSectionNumber = "";
48 @state() meetingTimes: MeetingTime[] = [];
49 @state() sections: string[] = [];
50 @state() editingClass = {
51 courseCode: "",
52 courseName: "",
53 professor: "",
54 semester: "",
55 year: new Date().getFullYear(),
56 };
57 @state() deleteState: {
58 id: string;
59 type: "class" | "waitlist";
60 clicks: number;
61 timeout: number | null;
62 } | null = null;
63
64 static override styles = css`
65 :host {
66 display: block;
67 }
68
69 .header {
70 display: flex;
71 justify-content: space-between;
72 align-items: center;
73 margin-bottom: 1.5rem;
74 gap: 1rem;
75 }
76
77 .search {
78 flex: 1;
79 max-width: 30rem;
80 padding: 0.5rem 0.75rem;
81 border: 2px solid var(--secondary);
82 border-radius: 4px;
83 font-size: 1rem;
84 font-family: inherit;
85 background: var(--background);
86 color: var(--text);
87 }
88
89 .search:focus {
90 outline: none;
91 border-color: var(--primary);
92 }
93
94 .create-btn {
95 padding: 0.75rem 1.5rem;
96 background: var(--primary);
97 color: white;
98 border: 2px solid var(--primary);
99 border-radius: 6px;
100 font-size: 1rem;
101 font-weight: 500;
102 cursor: pointer;
103 transition: all 0.2s;
104 font-family: inherit;
105 white-space: nowrap;
106 }
107
108 .create-btn:hover {
109 background: var(--gunmetal);
110 border-color: var(--gunmetal);
111 }
112
113 .classes-grid {
114 display: grid;
115 gap: 1rem;
116 }
117
118 .class-card {
119 background: var(--background);
120 border: 2px solid var(--secondary);
121 border-radius: 8px;
122 padding: 1.25rem;
123 transition: all 0.2s;
124 }
125
126 .class-card:hover {
127 border-color: var(--primary);
128 }
129
130 .class-card.archived {
131 opacity: 0.6;
132 border-style: dashed;
133 }
134
135 .class-header {
136 display: flex;
137 justify-content: space-between;
138 align-items: flex-start;
139 gap: 1rem;
140 margin-bottom: 0.75rem;
141 }
142
143 .class-info {
144 flex: 1;
145 }
146
147 .course-code {
148 font-size: 0.875rem;
149 font-weight: 600;
150 color: var(--accent);
151 text-transform: uppercase;
152 }
153
154 .class-name {
155 font-size: 1.125rem;
156 font-weight: 600;
157 margin: 0.25rem 0;
158 color: var(--text);
159 }
160
161 .class-meta {
162 display: flex;
163 gap: 1rem;
164 font-size: 0.875rem;
165 color: var(--paynes-gray);
166 flex-wrap: wrap;
167 }
168
169 .badge {
170 display: inline-block;
171 padding: 0.25rem 0.5rem;
172 border-radius: 4px;
173 font-size: 0.75rem;
174 font-weight: 600;
175 text-transform: uppercase;
176 }
177
178 .badge.archived {
179 background: var(--secondary);
180 color: var(--text);
181 }
182
183 .actions {
184 display: flex;
185 gap: 0.5rem;
186 flex-wrap: wrap;
187 }
188
189 button {
190 padding: 0.5rem 1rem;
191 border: 2px solid;
192 border-radius: 6px;
193 font-size: 0.875rem;
194 font-weight: 500;
195 cursor: pointer;
196 transition: all 0.2s;
197 font-family: inherit;
198 }
199
200 .btn-archive {
201 background: transparent;
202 color: var(--paynes-gray);
203 border-color: var(--secondary);
204 }
205
206 .btn-archive:hover {
207 border-color: var(--paynes-gray);
208 }
209
210 .btn-delete {
211 background: transparent;
212 color: #dc2626;
213 border-color: #dc2626;
214 }
215
216 .btn-delete:hover {
217 background: #dc2626;
218 color: white;
219 }
220
221 button:disabled {
222 opacity: 0.6;
223 cursor: not-allowed;
224 }
225
226 .empty-state {
227 text-align: center;
228 padding: 3rem 2rem;
229 color: var(--paynes-gray);
230 }
231
232 .loading {
233 text-align: center;
234 padding: 3rem 2rem;
235 color: var(--paynes-gray);
236 }
237
238 .error-banner {
239 background: #fecaca;
240 border: 2px solid rgba(220, 38, 38, 0.8);
241 border-radius: 6px;
242 padding: 1rem;
243 margin-bottom: 1.5rem;
244 color: #dc2626;
245 font-weight: 500;
246 }
247
248 .tabs {
249 display: flex;
250 gap: 1rem;
251 margin-bottom: 2rem;
252 border-bottom: 2px solid var(--secondary);
253 }
254
255 .tab {
256 padding: 0.75rem 1.5rem;
257 background: transparent;
258 border: none;
259 border-radius: 0;
260 color: var(--text);
261 cursor: pointer;
262 font-size: 1rem;
263 font-weight: 500;
264 font-family: inherit;
265 border-bottom: 2px solid transparent;
266 margin-bottom: -2px;
267 transition: all 0.2s;
268 }
269
270 .tab:hover {
271 color: var(--primary);
272 }
273
274 .tab.active {
275 color: var(--primary);
276 border-bottom-color: var(--primary);
277 }
278
279 .tab-badge {
280 display: inline-block;
281 margin-left: 0.5rem;
282 padding: 0.125rem 0.5rem;
283 background: var(--accent);
284 color: white;
285 border-radius: 12px;
286 font-size: 0.75rem;
287 font-weight: 600;
288 }
289
290 .modal-overlay {
291 position: fixed;
292 top: 0;
293 left: 0;
294 right: 0;
295 bottom: 0;
296 background: rgba(0, 0, 0, 0.5);
297 display: flex;
298 align-items: center;
299 justify-content: center;
300 z-index: 1000;
301 padding: 1rem;
302 }
303
304 .modal {
305 background: var(--background);
306 border: 2px solid var(--secondary);
307 border-radius: 12px;
308 padding: 2rem;
309 max-width: 32rem;
310 width: 100%;
311 max-height: 90vh;
312 overflow-y: auto;
313 }
314
315 .modal-title {
316 margin: 0 0 1.5rem 0;
317 color: var(--text);
318 font-size: 1.5rem;
319 }
320
321 .form-group {
322 margin-bottom: 1rem;
323 }
324
325 .form-group label {
326 display: block;
327 margin-bottom: 0.5rem;
328 font-weight: 500;
329 color: var(--text);
330 font-size: 0.875rem;
331 }
332
333 .form-group input {
334 width: 100%;
335 padding: 0.75rem;
336 border: 2px solid var(--secondary);
337 border-radius: 6px;
338 font-size: 1rem;
339 font-family: inherit;
340 background: var(--background);
341 color: var(--text);
342 box-sizing: border-box;
343 }
344
345 .form-group input:focus,
346 .form-group select:focus {
347 outline: none;
348 border-color: var(--primary);
349 }
350
351 .form-group select {
352 width: 100%;
353 padding: 0.75rem;
354 border: 2px solid var(--secondary);
355 border-radius: 6px;
356 font-size: 1rem;
357 font-family: inherit;
358 background: var(--background);
359 color: var(--text);
360 box-sizing: border-box;
361 }
362
363 .form-grid {
364 display: grid;
365 grid-template-columns: 1fr 1fr;
366 gap: 1rem;
367 margin-bottom: 1rem;
368 }
369
370 .form-group-full {
371 grid-column: 1 / -1;
372 }
373
374 .meeting-times-list {
375 display: flex;
376 flex-direction: column;
377 gap: 0.5rem;
378 }
379
380 .meeting-time-row {
381 display: flex;
382 gap: 0.5rem;
383 align-items: center;
384 }
385
386 .meeting-time-row input {
387 flex: 1;
388 }
389
390 .btn-remove {
391 padding: 0.5rem;
392 background: transparent;
393 color: #dc2626;
394 border: 2px solid #dc2626;
395 border-radius: 6px;
396 cursor: pointer;
397 font-size: 0.875rem;
398 font-weight: 500;
399 transition: all 0.2s;
400 }
401
402 .btn-remove:hover {
403 background: #dc2626;
404 color: white;
405 }
406
407 .btn-add {
408 margin-top: 0.5rem;
409 padding: 0.5rem 1rem;
410 background: transparent;
411 color: var(--primary);
412 border: 2px solid var(--primary);
413 border-radius: 6px;
414 cursor: pointer;
415 font-size: 0.875rem;
416 font-weight: 500;
417 transition: all 0.2s;
418 }
419
420 .btn-add:hover {
421 background: var(--primary);
422 color: white;
423 }
424
425 .modal-actions {
426 display: flex;
427 gap: 0.75rem;
428 justify-content: flex-end;
429 margin-top: 1.5rem;
430 }
431
432 .btn-submit {
433 padding: 0.75rem 1.5rem;
434 background: var(--primary);
435 color: white;
436 border: 2px solid var(--primary);
437 border-radius: 6px;
438 font-size: 1rem;
439 font-weight: 500;
440 cursor: pointer;
441 transition: all 0.2s;
442 font-family: inherit;
443 }
444
445 .btn-submit:hover:not(:disabled) {
446 background: var(--gunmetal);
447 border-color: var(--gunmetal);
448 }
449
450 .btn-submit:disabled {
451 opacity: 0.6;
452 cursor: not-allowed;
453 }
454
455 .btn-cancel {
456 padding: 0.75rem 1.5rem;
457 background: transparent;
458 color: var(--text);
459 border: 2px solid var(--secondary);
460 border-radius: 6px;
461 font-size: 1rem;
462 font-weight: 500;
463 cursor: pointer;
464 transition: all 0.2s;
465 font-family: inherit;
466 }
467
468 .btn-cancel:hover {
469 border-color: var(--primary);
470 color: var(--primary);
471 }
472 `;
473
474 override async connectedCallback() {
475 super.connectedCallback();
476
477 // Check for subtab query parameter
478 const params = new URLSearchParams(window.location.search);
479 const subtab = params.get("subtab");
480 if (subtab && this.isValidSubtab(subtab)) {
481 this.activeTab = subtab as "classes" | "waitlist";
482 } else {
483 // Set default subtab in URL if on classes tab
484 this.setActiveTab(this.activeTab);
485 }
486
487 await this.loadData();
488 }
489
490 private isValidSubtab(subtab: string): boolean {
491 return ["classes", "waitlist"].includes(subtab);
492 }
493
494 private setActiveTab(tab: "classes" | "waitlist") {
495 this.activeTab = tab;
496 // Update URL without reloading page
497 const url = new URL(window.location.href);
498 url.searchParams.set("subtab", tab);
499 window.history.pushState({}, "", url);
500 }
501
502 private async loadData() {
503 this.isLoading = true;
504 this.error = "";
505
506 try {
507 const [classesRes, waitlistRes] = await Promise.all([
508 fetch("/api/admin/classes"),
509 fetch("/api/admin/waitlist"),
510 ]);
511
512 if (!classesRes.ok || !waitlistRes.ok) {
513 throw new Error("Failed to load data");
514 }
515
516 const classesData = await classesRes.json();
517 const waitlistData = await waitlistRes.json();
518
519 // Flatten grouped classes into array
520 const groupedClasses = classesData.classes || {};
521 this.classes = Object.values(groupedClasses).flat();
522 this.waitlist = waitlistData.waitlist || [];
523 } catch {
524 this.error = "Failed to load data. Please try again.";
525 } finally {
526 this.isLoading = false;
527 }
528 }
529
530 private handleSearch(e: Event) {
531 this.searchTerm = (e.target as HTMLInputElement).value.toLowerCase();
532 }
533
534 private async handleToggleArchive(classId: string) {
535 try {
536 // Find the class to toggle its archived state
537 const classToToggle = this.classes.find((c) => c.id === classId);
538 if (!classToToggle) return;
539
540 const response = await fetch(`/api/classes/${classId}/archive`, {
541 method: "PUT",
542 headers: { "Content-Type": "application/json" },
543 body: JSON.stringify({ archived: !classToToggle.archived }),
544 });
545
546 if (!response.ok) {
547 throw new Error("Failed to update class");
548 }
549
550 // Update local state instead of reloading
551 this.classes = this.classes.map((c) =>
552 c.id === classId ? { ...c, archived: !c.archived } : c,
553 );
554 } catch {
555 this.error = "Failed to update class. Please try again.";
556 }
557 }
558
559 private handleDeleteClick(id: string, type: "class" | "waitlist") {
560 // If this is a different item or timeout expired, reset
561 if (
562 !this.deleteState ||
563 this.deleteState.id !== id ||
564 this.deleteState.type !== type
565 ) {
566 // Clear any existing timeout
567 if (this.deleteState?.timeout) {
568 clearTimeout(this.deleteState.timeout);
569 }
570
571 // Set first click
572 const timeout = window.setTimeout(() => {
573 this.deleteState = null;
574 }, 1000);
575
576 this.deleteState = { id, type, clicks: 1, timeout };
577 return;
578 }
579
580 // Increment clicks
581 const newClicks = this.deleteState.clicks + 1;
582
583 // Clear existing timeout
584 if (this.deleteState.timeout) {
585 clearTimeout(this.deleteState.timeout);
586 }
587
588 // Third click - actually delete
589 if (newClicks === 3) {
590 this.deleteState = null;
591 if (type === "class") {
592 this.performDeleteClass(id);
593 } else {
594 this.performDeleteWaitlist(id);
595 }
596 return;
597 }
598
599 // Second click - reset timeout
600 const timeout = window.setTimeout(() => {
601 this.deleteState = null;
602 }, 1000);
603
604 this.deleteState = { id, type, clicks: newClicks, timeout };
605 }
606
607 private getDeleteButtonText(id: string, type: "class" | "waitlist"): string {
608 if (
609 !this.deleteState ||
610 this.deleteState.id !== id ||
611 this.deleteState.type !== type
612 ) {
613 return "Delete";
614 }
615
616 if (this.deleteState.clicks === 1) {
617 return "Are you sure?";
618 }
619
620 if (this.deleteState.clicks === 2) {
621 return "Final warning!";
622 }
623
624 return "Delete";
625 }
626
627 private async performDeleteClass(classId: string) {
628 try {
629 const response = await fetch(`/api/classes/${classId}`, {
630 method: "DELETE",
631 });
632
633 if (!response.ok) {
634 throw new Error("Failed to delete class");
635 }
636
637 await this.loadData();
638 } catch {
639 this.error = "Failed to delete class. Please try again.";
640 }
641 }
642
643 private async performDeleteWaitlist(id: string) {
644 try {
645 const response = await fetch(`/api/admin/waitlist/${id}`, {
646 method: "DELETE",
647 });
648
649 if (!response.ok) {
650 throw new Error("Failed to delete waitlist entry");
651 }
652
653 await this.loadData();
654 } catch {
655 this.error = "Failed to delete waitlist entry. Please try again.";
656 }
657 }
658
659 private handleCreateClass() {
660 // Set empty form for creating new class
661 this.approvingEntry = null;
662 this.editingClass = {
663 courseCode: "",
664 courseName: "",
665 professor: "",
666 semester: "",
667 year: new Date().getFullYear(),
668 };
669 this.meetingTimes = [];
670 this.showModal = true;
671 }
672
673 private async handleEditSections(classId: string) {
674 try {
675 const response = await fetch(`/api/classes/${classId}`);
676 if (!response.ok) throw new Error("Failed to load class");
677
678 const data = await response.json();
679 this.editingClassId = classId;
680 this.editingClassInfo = data.class;
681 this.editingClassSections = data.sections || [];
682 this.newSectionNumber = "";
683 this.showClassSettingsModal = true;
684 } catch {
685 this.error = "Failed to load class details";
686 }
687 }
688
689 private async handleAddSection() {
690 if (!this.newSectionNumber.trim() || !this.editingClassId) return;
691
692 try {
693 const response = await fetch(`/api/classes/${this.editingClassId}/sections`, {
694 method: "POST",
695 headers: { "Content-Type": "application/json" },
696 body: JSON.stringify({ section_number: this.newSectionNumber.trim() }),
697 });
698
699 if (!response.ok) {
700 const data = await response.json();
701 this.error = data.error || "Failed to add section";
702 return;
703 }
704
705 const newSection = await response.json();
706 this.editingClassSections = [...this.editingClassSections, newSection];
707 this.newSectionNumber = "";
708 } catch {
709 this.error = "Failed to add section";
710 }
711 }
712
713 private async handleDeleteSection(sectionId: string) {
714 if (!this.editingClassId) return;
715
716 try {
717 const response = await fetch(`/api/classes/${this.editingClassId}/sections/${sectionId}`, {
718 method: "DELETE",
719 });
720
721 if (!response.ok) {
722 const data = await response.json();
723 this.error = data.error || "Failed to delete section";
724 return;
725 }
726
727 this.editingClassSections = this.editingClassSections.filter(s => s.id !== sectionId);
728 } catch {
729 this.error = "Failed to delete section";
730 }
731 }
732
733 private handleCloseSectionsModal() {
734 this.showClassSettingsModal = false;
735 this.editingClassId = null;
736 this.editingClassInfo = null;
737 this.editingClassSections = [];
738 this.newSectionNumber = "";
739 this.loadData();
740 }
741
742
743 private getFilteredClasses() {
744 if (!this.searchTerm) return this.classes;
745
746 return this.classes.filter((cls) => {
747 const searchStr = this.searchTerm;
748 return (
749 cls.course_code.toLowerCase().includes(searchStr) ||
750 cls.name.toLowerCase().includes(searchStr) ||
751 cls.professor.toLowerCase().includes(searchStr)
752 );
753 });
754 }
755
756 override render() {
757 if (this.isLoading) {
758 return html`<div class="loading">Loading...</div>`;
759 }
760
761 const filteredClasses = this.getFilteredClasses();
762
763 return html`
764 ${this.error ? html`<div class="error-banner">${this.error}</div>` : ""}
765
766 <div class="tabs">
767 <button
768 class="tab ${this.activeTab === "classes" ? "active" : ""}"
769 @click=${() => {
770 this.setActiveTab("classes");
771 }}
772 >
773 Classes
774 </button>
775 <button
776 class="tab ${this.activeTab === "waitlist" ? "active" : ""}"
777 @click=${() => {
778 this.setActiveTab("waitlist");
779 }}
780 >
781 Waitlist
782 ${this.waitlist.length > 0 ? html`<span class="tab-badge">${this.waitlist.length}</span>` : ""}
783 </button>
784 </div>
785
786 ${
787 this.activeTab === "classes"
788 ? this.renderClasses(filteredClasses)
789 : this.renderWaitlist()
790 }
791
792 ${this.showModal ? this.renderApprovalModal() : ""}
793 ${this.showClassSettingsModal ? this.renderClassSettingsModal() : ""}
794 `;
795 }
796
797 private renderClasses(filteredClasses: Class[]) {
798 return html`
799 <div class="header">
800 <input
801 type="text"
802 class="search"
803 placeholder="Search classes..."
804 @input=${this.handleSearch}
805 .value=${this.searchTerm}
806 />
807 <button class="create-btn" @click=${this.handleCreateClass}>
808 + Create Class
809 </button>
810 </div>
811
812 ${
813 filteredClasses.length === 0
814 ? html`
815 <div class="empty-state">
816 ${this.searchTerm ? "No classes found matching your search" : "No classes yet"}
817 </div>
818 `
819 : html`
820 <div class="classes-grid">
821 ${filteredClasses.map(
822 (cls) => html`
823 <div
824 class="class-card ${cls.archived ? "archived" : ""}"
825 @click=${() => this.handleEditSections(cls.id)}
826 style="cursor: pointer;"
827 >
828 <div class="class-header">
829 <div class="class-info">
830 <div class="course-code">${cls.course_code}</div>
831 <div class="class-name">${cls.name}</div>
832 <div class="class-meta">
833 <span>馃懁 ${cls.professor}</span>
834 <span>馃搮 ${cls.semester} ${cls.year}</span>
835 <span>馃懃 ${cls.student_count || 0} students</span>
836 <span>馃搫 ${cls.transcript_count || 0} transcripts</span>
837 ${cls.archived ? html`<span class="badge archived">Archived</span>` : ""}
838 </div>
839 </div>
840 </div>
841 </div>
842 `,
843 )}
844 </div>
845 `
846 }
847 `;
848 }
849
850 private renderClassSettingsModal() {
851 if (!this.showClassSettingsModal || !this.editingClassInfo) return html``;
852
853 return html`
854 <div class="modal-overlay" @click=${this.handleCloseSectionsModal}>
855 <div class="modal" @click=${(e: Event) => e.stopPropagation()} style="max-width: 48rem;">
856 <div class="modal-header">
857 <h2 class="modal-title">${this.editingClassInfo.course_code} - ${this.editingClassInfo.name}</h2>
858 <button class="close-btn" @click=${this.handleCloseSectionsModal} type="button">脳</button>
859 </div>
860
861 <div class="tabs" style="margin-bottom: 1.5rem;">
862 <div style="display: flex; gap: 0.5rem; border-bottom: 2px solid var(--secondary);">
863 <button
864 style="padding: 0.75rem 1.5rem; background: transparent; color: var(--primary); border: none; border-bottom: 2px solid var(--primary); font-weight: 600; cursor: pointer; margin-bottom: -2px;"
865 >
866 Sections
867 </button>
868 </div>
869 </div>
870
871 <!-- Sections Tab -->
872 <div style="margin-bottom: 1.5rem;">
873 <h3 style="margin-bottom: 1rem; color: var(--text);">Manage Sections</h3>
874
875 <div style="display: flex; gap: 0.75rem; margin-bottom: 1rem;">
876 <input
877 type="text"
878 placeholder="Section number (e.g., 01, 02, A, B)"
879 .value=${this.newSectionNumber}
880 @input=${(e: Event) => {
881 this.newSectionNumber = (e.target as HTMLInputElement).value;
882 }}
883 @keypress=${(e: KeyboardEvent) => {
884 if (e.key === "Enter") {
885 e.preventDefault();
886 this.handleAddSection();
887 }
888 }}
889 style="flex: 1; padding: 0.75rem; border: 2px solid var(--secondary); border-radius: 6px; font-size: 1rem; background: var(--background); color: var(--text);"
890 />
891 <button
892 @click=${this.handleAddSection}
893 ?disabled=${!this.newSectionNumber.trim()}
894 style="padding: 0.75rem 1.5rem; background: var(--primary); color: white; border: 2px solid var(--primary); border-radius: 6px; font-weight: 500; cursor: pointer; white-space: nowrap;"
895 >
896 Add Section
897 </button>
898 </div>
899
900 ${
901 this.editingClassSections.length === 0
902 ? html`<p style="color: var(--paynes-gray); text-align: center; padding: 2rem;">No sections yet. Add one above.</p>`
903 : html`
904 <div style="display: flex; flex-direction: column; gap: 0.5rem;">
905 ${this.editingClassSections.map(
906 (section) => html`
907 <div style="display: flex; justify-content: space-between; align-items: center; padding: 0.75rem; background: color-mix(in srgb, var(--secondary) 30%, transparent); border-radius: 6px;">
908 <span style="font-weight: 500;">Section ${section.section_number}</span>
909 <button
910 @click=${(e: Event) => {
911 e.stopPropagation();
912 this.handleDeleteSection(section.id);
913 }}
914 style="padding: 0.5rem 1rem; background: transparent; color: red; border: 2px solid red; border-radius: 4px; font-size: 0.875rem; cursor: pointer;"
915 >
916 Delete
917 </button>
918 </div>
919 `,
920 )}
921 </div>
922 `
923 }
924 </div>
925
926 <!-- Actions -->
927 <div style="display: flex; gap: 0.75rem; justify-content: space-between; padding-top: 1.5rem; border-top: 2px solid var(--secondary);">
928 <div style="display: flex; gap: 0.75rem;">
929 <button
930 @click=${(e: Event) => {
931 e.stopPropagation();
932 this.handleToggleArchive(this.editingClassId!);
933 this.handleCloseSectionsModal();
934 }}
935 style="padding: 0.75rem 1.5rem; background: transparent; color: var(--primary); border: 2px solid var(--primary); border-radius: 6px; font-weight: 500; cursor: pointer;"
936 >
937 ${this.editingClassInfo.archived ? "Unarchive" : "Archive"} Class
938 </button>
939 <button
940 @click=${(e: Event) => {
941 e.stopPropagation();
942 this.handleDeleteClick(this.editingClassId!, "class");
943 }}
944 style="padding: 0.75rem 1.5rem; background: transparent; color: red; border: 2px solid red; border-radius: 6px; font-weight: 500; cursor: pointer;"
945 >
946 ${this.getDeleteButtonText(this.editingClassId!, "class")}
947 </button>
948 </div>
949 <button
950 @click=${this.handleCloseSectionsModal}
951 style="padding: 0.75rem 1.5rem; background: var(--primary); color: white; border: 2px solid var(--primary); border-radius: 6px; font-weight: 500; cursor: pointer;"
952 >
953 Done
954 </button>
955 </div>
956
957 ${this.error ? html`<div style="color: red; margin-top: 1rem; padding: 0.75rem; background: color-mix(in srgb, red 10%, transparent); border-radius: 6px;">${this.error}</div>` : ""}
958 </div>
959 </div>
960 `;
961 }
962
963 private renderWaitlist() {
964 return html`
965 ${
966 this.waitlist.length === 0
967 ? html`
968 <div class="empty-state">No waitlist requests yet</div>
969 `
970 : html`
971 <div class="classes-grid">
972 ${this.waitlist.map(
973 (entry) => html`
974 <div class="class-card">
975 <div class="class-header">
976 <div class="class-info">
977 <div class="course-code">${entry.course_code}</div>
978 <div class="class-name">${entry.course_name}</div>
979 <div class="class-meta">
980 <span>馃懁 ${entry.professor}</span>
981 <span>馃搮 ${entry.semester} ${entry.year}</span>
982 </div>
983 ${
984 entry.additional_info
985 ? html`
986 <p style="margin-top: 0.75rem; font-size: 0.875rem; color: var(--paynes-gray);">
987 ${entry.additional_info}
988 </p>
989 `
990 : ""
991 }
992 </div>
993 <div class="actions">
994 <button
995 class="btn-archive"
996 @click=${() => this.handleApproveWaitlist(entry)}
997 >
998 Approve & Create Class
999 </button>
1000 <button
1001 class="btn-delete"
1002 @click=${() => this.handleDeleteClick(entry.id, "waitlist")}
1003 >
1004 ${this.getDeleteButtonText(entry.id, "waitlist")}
1005 </button>
1006 </div>
1007 </div>
1008 </div>
1009 `,
1010 )}
1011 </div>
1012 `
1013 }
1014 `;
1015 }
1016
1017 private handleApproveWaitlist(entry: WaitlistEntry) {
1018 this.approvingEntry = entry;
1019
1020 // Pre-fill form with waitlist data
1021 this.editingClass = {
1022 courseCode: entry.course_code,
1023 courseName: entry.course_name,
1024 professor: entry.professor,
1025 semester: entry.semester,
1026 year: entry.year,
1027 };
1028
1029 // Parse meeting times from JSON if available, otherwise use empty array
1030 if (entry.meeting_times) {
1031 try {
1032 const parsed = JSON.parse(entry.meeting_times);
1033 this.meetingTimes =
1034 Array.isArray(parsed) && parsed.length > 0 ? parsed : [];
1035 } catch {
1036 this.meetingTimes = [];
1037 }
1038 } else {
1039 this.meetingTimes = [];
1040 }
1041 this.showModal = true;
1042 }
1043
1044 private handleMeetingTimesChange(e: CustomEvent) {
1045 this.meetingTimes = e.detail;
1046 }
1047
1048 private handleSectionsChange(e: Event) {
1049 const value = (e.target as HTMLInputElement).value;
1050 this.sections = value
1051 .split(",")
1052 .map((s) => s.trim())
1053 .filter((s) => s);
1054 }
1055
1056 private handleClassFieldInput(field: string, e: Event) {
1057 const value = (e.target as HTMLInputElement | HTMLSelectElement).value;
1058 this.editingClass = { ...this.editingClass, [field]: value };
1059 }
1060
1061 private cancelApproval() {
1062 this.showModal = false;
1063 this.approvingEntry = null;
1064 this.meetingTimes = [];
1065 this.sections = [];
1066 this.editingClass = {
1067 courseCode: "",
1068 courseName: "",
1069 professor: "",
1070 semester: "",
1071 year: new Date().getFullYear(),
1072 };
1073 }
1074
1075 private async submitApproval() {
1076 if (this.meetingTimes.length === 0) {
1077 this.error = "Please add at least one meeting time";
1078 return;
1079 }
1080
1081 // Convert MeetingTime objects to label strings
1082 const labels = this.meetingTimes.map((t) => t.label);
1083
1084 try {
1085 const response = await fetch("/api/classes", {
1086 method: "POST",
1087 headers: { "Content-Type": "application/json" },
1088 body: JSON.stringify({
1089 course_code: this.editingClass.courseCode,
1090 name: this.editingClass.courseName,
1091 professor: this.editingClass.professor,
1092 semester: this.editingClass.semester,
1093 year: this.editingClass.year,
1094 meeting_times: labels,
1095 sections: this.sections.length > 0 ? this.sections : undefined,
1096 }),
1097 });
1098
1099 if (!response.ok) {
1100 const data = await response.json();
1101 console.error("Failed to create class:", data);
1102 throw new Error(data.error || "Failed to create class");
1103 }
1104
1105 // If approving from waitlist, delete the waitlist entry
1106 if (this.approvingEntry) {
1107 await fetch(`/api/admin/waitlist/${this.approvingEntry.id}`, {
1108 method: "DELETE",
1109 });
1110 }
1111
1112 await this.loadData();
1113
1114 this.setActiveTab("classes");
1115 this.showModal = false;
1116 this.approvingEntry = null;
1117 this.meetingTimes = [];
1118 this.editingClass = {
1119 courseCode: "",
1120 courseName: "",
1121 professor: "",
1122 semester: "",
1123 year: new Date().getFullYear(),
1124 };
1125 } catch (error) {
1126 console.error("Error in submitApproval:", error);
1127 this.error =
1128 error instanceof Error
1129 ? error.message
1130 : "Failed to create class. Please try again.";
1131 }
1132 }
1133
1134 private renderApprovalModal() {
1135 const isApproving = !!this.approvingEntry;
1136 const title = isApproving ? "Review & Create Class" : "Create New Class";
1137 const description = isApproving
1138 ? "Review the class details and make any edits before creating"
1139 : "Enter the class details below";
1140
1141 return html`
1142 <div class="modal-overlay" @click=${this.cancelApproval}>
1143 <div class="modal" @click=${(e: Event) => e.stopPropagation()}>
1144 <h2 class="modal-title">${title}</h2>
1145
1146 <p style="margin-bottom: 1.5rem; color: var(--paynes-gray);">
1147 ${description}
1148 </p>
1149
1150 ${this.error ? html`<div class="error-banner">${this.error}</div>` : ""}
1151
1152 <div class="form-grid">
1153 <div class="form-group">
1154 <label>Course Code *</label>
1155 <input
1156 type="text"
1157 required
1158 .value=${this.editingClass.courseCode}
1159 @input=${(e: Event) => this.handleClassFieldInput("courseCode", e)}
1160 />
1161 </div>
1162 <div class="form-group">
1163 <label>Course Name *</label>
1164 <input
1165 type="text"
1166 required
1167 .value=${this.editingClass.courseName}
1168 @input=${(e: Event) => this.handleClassFieldInput("courseName", e)}
1169 />
1170 </div>
1171 <div class="form-group">
1172 <label>Professor *</label>
1173 <input
1174 type="text"
1175 required
1176 .value=${this.editingClass.professor}
1177 @input=${(e: Event) => this.handleClassFieldInput("professor", e)}
1178 />
1179 </div>
1180 <div class="form-group">
1181 <label>Semester *</label>
1182 <select
1183 required
1184 .value=${this.editingClass.semester}
1185 @change=${(e: Event) => this.handleClassFieldInput("semester", e)}
1186 >
1187 <option value="">Select semester</option>
1188 <option value="Spring">Spring</option>
1189 <option value="Summer">Summer</option>
1190 <option value="Fall">Fall</option>
1191 <option value="Winter">Winter</option>
1192 </select>
1193 </div>
1194 <div class="form-group">
1195 <label>Year *</label>
1196 <input
1197 type="number"
1198 required
1199 min="2020"
1200 max="2030"
1201 .value=${this.editingClass.year.toString()}
1202 @input=${(e: Event) => this.handleClassFieldInput("year", e)}
1203 />
1204 </div>
1205 <div class="form-group form-group-full">
1206 <label>Meeting Times *</label>
1207 <meeting-time-picker
1208 .value=${this.meetingTimes}
1209 @change=${this.handleMeetingTimesChange}
1210 ></meeting-time-picker>
1211 </div>
1212 <div class="form-group form-group-full">
1213 <label>Sections (optional)</label>
1214 <input
1215 type="text"
1216 placeholder="e.g., 01, 02, 03 or A, B, C"
1217 .value=${this.sections.join(", ")}
1218 @input=${this.handleSectionsChange}
1219 />
1220 <div class="help-text">Comma-separated list of section numbers. Leave blank if no sections.</div>
1221 </div>
1222 </div>
1223
1224 <div class="modal-actions">
1225 <button class="btn-cancel" @click=${this.cancelApproval}>
1226 Cancel
1227 </button>
1228 <button
1229 class="btn-submit"
1230 @click=${this.submitApproval}
1231 ?disabled=${this.meetingTimes.length === 0}
1232 >
1233 Create Class
1234 </button>
1235 </div>
1236 </div>
1237 </div>
1238 `;
1239 }
1240}