馃 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}