wepee commited on
Commit
e6b7e49
·
1 Parent(s): 7be89db

add: page /dosen, /dosen/{id}

Browse files
resources/css/app.css CHANGED
@@ -109,6 +109,7 @@ :root {
109
  --app-primary-soft: #ccdcff;
110
  --app-primary-foreground: #ffffff;
111
  --app-danger: #ef4444;
 
112
  --app-placeholder: rgba(115, 115, 115, 0.42);
113
  --app-toggle-bg: rgba(255, 255, 255, 0.9);
114
  --app-shadow: 1px 1px 4.5px rgba(39, 100, 235, 0.36);
@@ -161,6 +162,22 @@ :root {
161
  --student-shadow: 0 24px 70px rgba(15, 23, 42, 0.08);
162
  --student-sidebar-shadow: 1px 0 0 rgba(15, 23, 42, 0.08);
163
  --student-composer-shadow: var(--app-composer-shadow);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
164
  }
165
 
166
  .dark {
@@ -211,6 +228,7 @@ .dark {
211
  --app-primary-soft: #c8d7ff;
212
  --app-primary-foreground: #ffffff;
213
  --app-danger: #ff4545;
 
214
  --app-placeholder: rgba(244, 244, 245, 0.42);
215
  --app-toggle-bg: #171717;
216
  --app-shadow: 0 0 20px rgba(52, 120, 255, 0.34);
@@ -259,6 +277,26 @@ .dark {
259
  --student-shadow: 0 24px 70px rgba(0, 0, 0, 0.18);
260
  --student-sidebar-shadow: 1px 0 0 var(--app-border);
261
  --student-composer-shadow: var(--app-composer-shadow);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
  }
263
 
264
  @layer base {
@@ -269,6 +307,17 @@ @layer base {
269
  body {
270
  @apply bg-background text-foreground;
271
  }
 
 
 
 
 
 
 
 
 
 
 
272
  }
273
 
274
  @layer components {
@@ -282,7 +331,7 @@ @layer components {
282
  }
283
 
284
  .welcome-theme-toggle {
285
- @apply absolute top-4 right-4 h-10 w-10 rounded-[10px] shadow-none backdrop-blur sm:top-8 sm:right-8;
286
  background-color: var(--welcome-toggle-bg);
287
  border-color: var(--welcome-card-active-border);
288
  color: var(--welcome-accent);
@@ -295,7 +344,7 @@ @layer components {
295
  }
296
 
297
  .welcome-title {
298
- @apply text-center text-[34px] leading-tight font-bold tracking-[0.01em] sm:text-[40px];
299
  color: var(--welcome-title);
300
  }
301
 
@@ -345,7 +394,7 @@ @layer components {
345
  }
346
 
347
  .welcome-entry-title {
348
- @apply truncate text-left text-lg leading-tight font-semibold;
349
  color: var(--welcome-title);
350
  }
351
 
@@ -365,7 +414,7 @@ @layer components {
365
  }
366
 
367
  .welcome-register-link {
368
- @apply font-medium underline-offset-4 hover:underline focus-visible:ring-2 focus-visible:outline-none;
369
  color: var(--welcome-accent);
370
  --tw-ring-color: color-mix(
371
  in oklab,
@@ -414,7 +463,7 @@ @layer components {
414
  }
415
 
416
  .register-back-link {
417
- @apply inline-flex text-sm font-semibold underline-offset-4 transition-colors hover:underline focus-visible:ring-2 focus-visible:outline-none;
418
  color: var(--welcome-subtitle);
419
  --tw-ring-color: color-mix(
420
  in oklab,
@@ -424,7 +473,7 @@ @layer components {
424
  }
425
 
426
  .register-heading {
427
- @apply mt-2 text-center text-2xl leading-tight font-semibold;
428
  color: var(--welcome-title);
429
  }
430
 
@@ -437,7 +486,7 @@ @layer components {
437
  }
438
 
439
  .register-label {
440
- @apply text-base leading-none font-medium;
441
  color: var(--welcome-title);
442
  }
443
 
@@ -462,6 +511,20 @@ @layer components {
462
  );
463
  }
464
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
465
  .register-submit {
466
  @apply mt-1 h-[43px] w-full rounded-[6px] border text-base font-semibold shadow-none;
467
  background-color: var(--welcome-accent) !important;
@@ -498,7 +561,7 @@ @layer components {
498
  }
499
 
500
  .login-remember-label {
501
- @apply text-sm leading-none font-normal;
502
  color: var(--welcome-subtitle);
503
  }
504
 
@@ -520,7 +583,7 @@ @layer components {
520
  }
521
 
522
  .student-dashboard-shell {
523
- @apply min-h-svh;
524
  background-color: var(--student-bg);
525
  color: var(--student-text);
526
  }
@@ -548,7 +611,7 @@ @layer components {
548
  }
549
 
550
  .student-logo-action {
551
- @apply flex size-8 shrink-0 items-center justify-center rounded-[8px] transition-transform outline-none focus-visible:ring-2;
552
  --tw-ring-color: color-mix(
553
  in oklab,
554
  var(--student-primary) 45%,
@@ -639,7 +702,7 @@ @layer components {
639
  }
640
 
641
  .student-history-delete {
642
- @apply absolute top-1/2 right-2 flex size-6 -translate-y-1/2 items-center justify-center rounded-[6px] opacity-0 transition-opacity;
643
  color: var(--student-danger);
644
  }
645
 
@@ -656,6 +719,71 @@ @layer components {
656
  );
657
  }
658
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
659
  .student-history-menu-icon {
660
  @apply hidden;
661
  color: var(--student-text);
@@ -667,7 +795,7 @@ @layer components {
667
  }
668
 
669
  .student-history-title {
670
- @apply block truncate text-[11px] leading-tight font-medium;
671
  color: var(--student-text);
672
  }
673
 
@@ -702,7 +830,7 @@ @layer components {
702
  }
703
 
704
  .student-user-name {
705
- @apply block truncate text-[11px] leading-tight font-semibold;
706
  color: var(--student-text);
707
  }
708
 
@@ -766,13 +894,13 @@ @layer components {
766
  }
767
 
768
  .student-workspace {
769
- @apply flex min-w-0 flex-1 flex-col;
770
  background-color: var(--student-bg);
771
  color: var(--student-text);
772
  }
773
 
774
  .student-topbar {
775
- @apply flex h-[72px] shrink-0 items-center justify-between border-b px-5 sm:px-6;
776
  background-color: var(--student-surface);
777
  border-color: var(--student-border);
778
  }
@@ -800,7 +928,7 @@ @layer components {
800
  }
801
 
802
  .student-chat-stage {
803
- @apply flex flex-1 items-center justify-center px-4 py-10;
804
  }
805
 
806
  .student-empty-state {
@@ -818,7 +946,7 @@ @layer components {
818
  }
819
 
820
  .student-greeting h2 {
821
- @apply text-3xl leading-tight font-bold tracking-[-0.02em];
822
  color: var(--student-text);
823
  }
824
 
@@ -891,17 +1019,17 @@ @layer components {
891
  }
892
 
893
  .student-floating-send {
894
- @apply absolute right-5 bottom-4 size-8 rounded-[8px] shadow-none;
895
  background-color: var(--student-primary) !important;
896
  color: var(--student-primary-foreground) !important;
897
  }
898
 
899
  .student-chat-stage--session {
900
- @apply flex-col items-stretch justify-between overflow-hidden px-5 py-0;
901
  }
902
 
903
  .student-chat-thread {
904
- @apply min-h-0 w-full flex-1 overflow-y-auto py-8;
905
  }
906
 
907
  .student-chat-messages {
@@ -914,7 +1042,7 @@ @layer components {
914
  }
915
 
916
  .student-chat-status {
917
- @apply mt-0 mb-4;
918
  }
919
 
920
  .student-message {
@@ -957,7 +1085,7 @@ @layer components {
957
  }
958
 
959
  .student-message-content {
960
- @apply leading-relaxed whitespace-pre-wrap;
961
  }
962
 
963
  .student-message-time {
@@ -1005,6 +1133,775 @@ @layer components {
1005
  color: var(--student-muted);
1006
  }
1007
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1008
  @media (max-width: 640px) {
1009
  .student-chat-stage--session {
1010
  @apply px-3;
@@ -1017,5 +1914,45 @@ @layer components {
1017
  .student-chat-composer {
1018
  @apply pb-4;
1019
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1020
  }
1021
  }
 
109
  --app-primary-soft: #ccdcff;
110
  --app-primary-foreground: #ffffff;
111
  --app-danger: #ef4444;
112
+ --app-success: #16a34a;
113
  --app-placeholder: rgba(115, 115, 115, 0.42);
114
  --app-toggle-bg: rgba(255, 255, 255, 0.9);
115
  --app-shadow: 1px 1px 4.5px rgba(39, 100, 235, 0.36);
 
162
  --student-shadow: 0 24px 70px rgba(15, 23, 42, 0.08);
163
  --student-sidebar-shadow: 1px 0 0 rgba(15, 23, 42, 0.08);
164
  --student-composer-shadow: var(--app-composer-shadow);
165
+ --lecturer-bg: var(--app-bg);
166
+ --lecturer-sidebar-bg: var(--app-sidebar-bg);
167
+ --lecturer-surface: var(--app-surface);
168
+ --lecturer-surface-soft: var(--app-surface-soft);
169
+ --lecturer-border: var(--app-border);
170
+ --lecturer-border-soft: var(--app-border-soft);
171
+ --lecturer-border-strong: var(--app-border-strong);
172
+ --lecturer-text: var(--app-text);
173
+ --lecturer-muted: var(--app-muted);
174
+ --lecturer-primary: var(--app-primary);
175
+ --lecturer-primary-soft: var(--app-primary-soft);
176
+ --lecturer-primary-foreground: var(--app-primary-foreground);
177
+ --lecturer-danger: var(--app-danger);
178
+ --lecturer-success: var(--app-success);
179
+ --lecturer-shadow: 0 18px 42px rgba(15, 23, 42, 0.08);
180
+ --lecturer-sidebar-shadow: 1px 0 0 rgba(15, 23, 42, 0.08);
181
  }
182
 
183
  .dark {
 
228
  --app-primary-soft: #c8d7ff;
229
  --app-primary-foreground: #ffffff;
230
  --app-danger: #ff4545;
231
+ --app-success: #22c55e;
232
  --app-placeholder: rgba(244, 244, 245, 0.42);
233
  --app-toggle-bg: #171717;
234
  --app-shadow: 0 0 20px rgba(52, 120, 255, 0.34);
 
277
  --student-shadow: 0 24px 70px rgba(0, 0, 0, 0.18);
278
  --student-sidebar-shadow: 1px 0 0 var(--app-border);
279
  --student-composer-shadow: var(--app-composer-shadow);
280
+ --lecturer-bg: var(--app-bg);
281
+ --lecturer-sidebar-bg: var(--app-sidebar-bg);
282
+ --lecturer-surface: var(--app-surface);
283
+ --lecturer-surface-soft: var(--app-surface-soft);
284
+ --lecturer-border: var(--app-border);
285
+ --lecturer-border-soft: var(--app-border-soft);
286
+ --lecturer-border-strong: var(--app-border-strong);
287
+ --lecturer-text: var(--app-text);
288
+ --lecturer-muted: var(--app-muted);
289
+ --lecturer-primary: var(--app-primary);
290
+ --lecturer-primary-soft: color-mix(
291
+ in oklab,
292
+ var(--app-primary) 22%,
293
+ var(--app-surface)
294
+ );
295
+ --lecturer-primary-foreground: var(--app-primary-foreground);
296
+ --lecturer-danger: var(--app-danger);
297
+ --lecturer-success: var(--app-success);
298
+ --lecturer-shadow: 0 18px 42px rgba(0, 0, 0, 0.2);
299
+ --lecturer-sidebar-shadow: 1px 0 0 var(--app-border);
300
  }
301
 
302
  @layer base {
 
307
  body {
308
  @apply bg-background text-foreground;
309
  }
310
+
311
+ button:not(:disabled),
312
+ [role='button']:not([aria-disabled='true']),
313
+ [data-slot='button']:not([aria-disabled='true']) {
314
+ @apply cursor-pointer;
315
+ }
316
+
317
+ button:disabled,
318
+ [aria-disabled='true'] {
319
+ @apply cursor-not-allowed;
320
+ }
321
  }
322
 
323
  @layer components {
 
331
  }
332
 
333
  .welcome-theme-toggle {
334
+ @apply absolute right-4 top-4 h-10 w-10 rounded-[10px] shadow-none backdrop-blur sm:right-8 sm:top-8;
335
  background-color: var(--welcome-toggle-bg);
336
  border-color: var(--welcome-card-active-border);
337
  color: var(--welcome-accent);
 
344
  }
345
 
346
  .welcome-title {
347
+ @apply text-center text-[34px] font-bold leading-tight tracking-[0.01em] sm:text-[40px];
348
  color: var(--welcome-title);
349
  }
350
 
 
394
  }
395
 
396
  .welcome-entry-title {
397
+ @apply truncate text-left text-lg font-semibold leading-tight;
398
  color: var(--welcome-title);
399
  }
400
 
 
414
  }
415
 
416
  .welcome-register-link {
417
+ @apply font-medium underline-offset-4 hover:underline focus-visible:outline-none focus-visible:ring-2;
418
  color: var(--welcome-accent);
419
  --tw-ring-color: color-mix(
420
  in oklab,
 
463
  }
464
 
465
  .register-back-link {
466
+ @apply inline-flex text-sm font-semibold underline-offset-4 transition-colors hover:underline focus-visible:outline-none focus-visible:ring-2;
467
  color: var(--welcome-subtitle);
468
  --tw-ring-color: color-mix(
469
  in oklab,
 
473
  }
474
 
475
  .register-heading {
476
+ @apply mt-2 text-center text-2xl font-semibold leading-tight;
477
  color: var(--welcome-title);
478
  }
479
 
 
486
  }
487
 
488
  .register-label {
489
+ @apply text-base font-medium leading-none;
490
  color: var(--welcome-title);
491
  }
492
 
 
511
  );
512
  }
513
 
514
+ .register-input[aria-invalid='true'] {
515
+ border-color: var(--register-danger) !important;
516
+ --tw-ring-color: color-mix(
517
+ in oklab,
518
+ var(--register-danger) 28%,
519
+ transparent
520
+ );
521
+ }
522
+
523
+ .register-error {
524
+ @apply text-sm font-medium leading-snug;
525
+ color: var(--register-danger);
526
+ }
527
+
528
  .register-submit {
529
  @apply mt-1 h-[43px] w-full rounded-[6px] border text-base font-semibold shadow-none;
530
  background-color: var(--welcome-accent) !important;
 
561
  }
562
 
563
  .login-remember-label {
564
+ @apply text-sm font-normal leading-none;
565
  color: var(--welcome-subtitle);
566
  }
567
 
 
583
  }
584
 
585
  .student-dashboard-shell {
586
+ @apply h-svh min-h-svh overflow-hidden;
587
  background-color: var(--student-bg);
588
  color: var(--student-text);
589
  }
 
611
  }
612
 
613
  .student-logo-action {
614
+ @apply flex size-8 shrink-0 items-center justify-center rounded-[8px] outline-none transition-transform focus-visible:ring-2;
615
  --tw-ring-color: color-mix(
616
  in oklab,
617
  var(--student-primary) 45%,
 
702
  }
703
 
704
  .student-history-delete {
705
+ @apply absolute right-2 top-1/2 flex size-6 -translate-y-1/2 items-center justify-center rounded-[6px] opacity-0 transition-opacity;
706
  color: var(--student-danger);
707
  }
708
 
 
719
  );
720
  }
721
 
722
+ .student-delete-dialog {
723
+ @apply gap-5 rounded-[16px] border p-6 shadow-none sm:max-w-[440px];
724
+ background-color: var(--student-surface);
725
+ border-color: var(--student-border);
726
+ box-shadow: var(--student-shadow);
727
+ color: var(--student-text);
728
+ }
729
+
730
+ .student-delete-dialog-body {
731
+ @apply flex flex-col gap-3;
732
+ }
733
+
734
+ .student-delete-copy {
735
+ @apply text-sm leading-relaxed;
736
+ color: var(--student-muted);
737
+ }
738
+
739
+ .student-delete-copy strong {
740
+ color: var(--student-text);
741
+ }
742
+
743
+ .student-delete-error {
744
+ @apply rounded-[10px] border px-4 py-3 text-sm font-medium;
745
+ background-color: color-mix(
746
+ in oklab,
747
+ var(--student-danger) 8%,
748
+ var(--student-surface)
749
+ );
750
+ border-color: color-mix(
751
+ in oklab,
752
+ var(--student-danger) 24%,
753
+ transparent
754
+ );
755
+ color: var(--student-danger);
756
+ }
757
+
758
+ .student-delete-dialog-footer {
759
+ @apply gap-3 pt-1;
760
+ }
761
+
762
+ .student-delete-cancel {
763
+ @apply h-10 rounded-[8px] px-5 text-sm font-semibold shadow-none;
764
+ background-color: var(--student-surface) !important;
765
+ border-color: var(--student-border) !important;
766
+ color: var(--student-text) !important;
767
+ }
768
+
769
+ .student-delete-cancel:hover {
770
+ background-color: var(--student-surface-soft) !important;
771
+ }
772
+
773
+ .student-delete-confirm {
774
+ @apply h-10 rounded-[8px] px-5 text-sm font-semibold shadow-none;
775
+ background-color: var(--student-danger) !important;
776
+ color: var(--student-primary-foreground) !important;
777
+ }
778
+
779
+ .student-delete-confirm:hover {
780
+ background-color: color-mix(
781
+ in oklab,
782
+ var(--student-danger) 86%,
783
+ #000
784
+ ) !important;
785
+ }
786
+
787
  .student-history-menu-icon {
788
  @apply hidden;
789
  color: var(--student-text);
 
795
  }
796
 
797
  .student-history-title {
798
+ @apply block truncate text-[11px] font-medium leading-tight;
799
  color: var(--student-text);
800
  }
801
 
 
830
  }
831
 
832
  .student-user-name {
833
+ @apply block truncate text-[11px] font-semibold leading-tight;
834
  color: var(--student-text);
835
  }
836
 
 
894
  }
895
 
896
  .student-workspace {
897
+ @apply flex h-svh min-w-0 flex-1 flex-col overflow-hidden;
898
  background-color: var(--student-bg);
899
  color: var(--student-text);
900
  }
901
 
902
  .student-topbar {
903
+ @apply sticky top-0 z-20 flex h-[72px] shrink-0 items-center justify-between border-b px-5 sm:px-6;
904
  background-color: var(--student-surface);
905
  border-color: var(--student-border);
906
  }
 
928
  }
929
 
930
  .student-chat-stage {
931
+ @apply flex min-h-0 flex-1 items-center justify-center px-4 py-10;
932
  }
933
 
934
  .student-empty-state {
 
946
  }
947
 
948
  .student-greeting h2 {
949
+ @apply text-3xl font-bold leading-tight tracking-[-0.02em];
950
  color: var(--student-text);
951
  }
952
 
 
1019
  }
1020
 
1021
  .student-floating-send {
1022
+ @apply absolute bottom-4 right-5 size-8 rounded-[8px] shadow-none;
1023
  background-color: var(--student-primary) !important;
1024
  color: var(--student-primary-foreground) !important;
1025
  }
1026
 
1027
  .student-chat-stage--session {
1028
+ @apply min-h-0 flex-col items-stretch justify-between overflow-hidden px-5 py-0;
1029
  }
1030
 
1031
  .student-chat-thread {
1032
+ @apply min-h-0 w-full flex-1 overflow-y-auto overscroll-contain py-8;
1033
  }
1034
 
1035
  .student-chat-messages {
 
1042
  }
1043
 
1044
  .student-chat-status {
1045
+ @apply mb-4 mt-0;
1046
  }
1047
 
1048
  .student-message {
 
1085
  }
1086
 
1087
  .student-message-content {
1088
+ @apply whitespace-pre-wrap leading-relaxed;
1089
  }
1090
 
1091
  .student-message-time {
 
1133
  color: var(--student-muted);
1134
  }
1135
 
1136
+ .lecturer-dashboard-shell {
1137
+ @apply min-h-svh;
1138
+ background-color: var(--lecturer-bg);
1139
+ color: var(--lecturer-text);
1140
+ }
1141
+
1142
+ .lecturer-sidebar {
1143
+ border-color: var(--lecturer-border) !important;
1144
+ }
1145
+
1146
+ .lecturer-sidebar [data-sidebar='sidebar'] {
1147
+ background-color: var(--lecturer-sidebar-bg);
1148
+ border-color: var(--lecturer-border);
1149
+ box-shadow: var(--lecturer-sidebar-shadow);
1150
+ }
1151
+
1152
+ .lecturer-sidebar-header {
1153
+ @apply border-b;
1154
+ border-color: var(--lecturer-border);
1155
+ display: flex !important;
1156
+ height: 72px !important;
1157
+ flex-direction: row !important;
1158
+ align-items: center !important;
1159
+ justify-content: space-between !important;
1160
+ gap: 0 !important;
1161
+ padding: 0 20px !important;
1162
+ }
1163
+
1164
+ .lecturer-brand {
1165
+ @apply flex min-w-0 items-center gap-3 rounded-[10px] text-left outline-none focus-visible:ring-2;
1166
+ color: var(--lecturer-text);
1167
+ flex: 1 1 auto;
1168
+ --tw-ring-color: color-mix(
1169
+ in oklab,
1170
+ var(--lecturer-primary) 45%,
1171
+ transparent
1172
+ );
1173
+ }
1174
+
1175
+ .lecturer-brand-mark {
1176
+ @apply flex size-8 shrink-0 items-center justify-center rounded-[8px];
1177
+ background-color: var(--lecturer-primary);
1178
+ color: var(--lecturer-primary-foreground);
1179
+ }
1180
+
1181
+ .lecturer-brand-copy {
1182
+ @apply truncate text-base font-bold;
1183
+ }
1184
+
1185
+ .lecturer-sidebar-trigger {
1186
+ @apply rounded-[8px] shadow-none;
1187
+ color: var(--lecturer-text);
1188
+ width: 32px !important;
1189
+ height: 32px !important;
1190
+ }
1191
+
1192
+ .lecturer-sidebar-trigger:hover {
1193
+ background-color: var(--lecturer-surface-soft);
1194
+ color: var(--lecturer-text);
1195
+ }
1196
+
1197
+ .lecturer-sidebar-content {
1198
+ gap: 0 !important;
1199
+ }
1200
+
1201
+ .lecturer-nav-group {
1202
+ padding: 16px 20px !important;
1203
+ }
1204
+
1205
+ .lecturer-nav-list {
1206
+ gap: 8px !important;
1207
+ }
1208
+
1209
+ .lecturer-nav-button {
1210
+ @apply justify-start gap-2 rounded-[8px] text-xs font-semibold shadow-none;
1211
+ color: var(--lecturer-muted);
1212
+ height: 34px !important;
1213
+ padding: 0 10px !important;
1214
+ }
1215
+
1216
+ .lecturer-nav-button:hover,
1217
+ .lecturer-nav-button[data-active='true'] {
1218
+ background-color: var(--lecturer-primary-soft);
1219
+ color: var(--lecturer-primary);
1220
+ }
1221
+
1222
+ .lecturer-sidebar-footer {
1223
+ @apply mt-auto border-t;
1224
+ border-color: var(--lecturer-border);
1225
+ padding: 16px 20px !important;
1226
+ }
1227
+
1228
+ .lecturer-user-button {
1229
+ @apply mb-3 w-fit gap-2 rounded-[6px] shadow-none;
1230
+ background-color: var(--lecturer-surface-soft);
1231
+ color: var(--lecturer-text);
1232
+ height: auto !important;
1233
+ padding: 6px 8px !important;
1234
+ }
1235
+
1236
+ .lecturer-user-button:hover {
1237
+ background-color: var(--lecturer-surface-soft);
1238
+ color: var(--lecturer-text);
1239
+ }
1240
+
1241
+ .lecturer-user-avatar {
1242
+ @apply size-6 text-[8px];
1243
+ background-color: var(--lecturer-surface);
1244
+ color: var(--lecturer-text);
1245
+ }
1246
+
1247
+ .lecturer-user-copy {
1248
+ @apply min-w-0;
1249
+ }
1250
+
1251
+ .lecturer-user-name {
1252
+ @apply block truncate text-[11px] font-semibold leading-tight;
1253
+ color: var(--lecturer-text);
1254
+ }
1255
+
1256
+ .lecturer-user-role {
1257
+ @apply block truncate text-[10px] leading-tight;
1258
+ color: var(--lecturer-muted);
1259
+ }
1260
+
1261
+ .lecturer-logout-button {
1262
+ @apply justify-start gap-1 rounded-none text-[11px] font-normal shadow-none;
1263
+ color: var(--lecturer-danger);
1264
+ height: auto !important;
1265
+ padding: 0 !important;
1266
+ }
1267
+
1268
+ .lecturer-logout-button:hover {
1269
+ background-color: transparent;
1270
+ color: var(--lecturer-danger);
1271
+ }
1272
+
1273
+ .group[data-collapsible='icon'] .lecturer-sidebar-header {
1274
+ justify-content: center !important;
1275
+ padding: 0 !important;
1276
+ }
1277
+
1278
+ .group[data-collapsible='icon'] .lecturer-brand-copy,
1279
+ .group[data-collapsible='icon'] .lecturer-nav-button span,
1280
+ .group[data-collapsible='icon'] .lecturer-user-copy,
1281
+ .group[data-collapsible='icon'] .lecturer-logout-button span {
1282
+ @apply hidden;
1283
+ }
1284
+
1285
+ .group[data-collapsible='icon'] .lecturer-brand {
1286
+ flex: 0 0 auto;
1287
+ justify-content: center;
1288
+ }
1289
+
1290
+ .group[data-collapsible='icon'] .lecturer-sidebar-trigger {
1291
+ display: none !important;
1292
+ }
1293
+
1294
+ .group[data-collapsible='icon'] .lecturer-nav-group {
1295
+ padding: 20px 12px !important;
1296
+ }
1297
+
1298
+ .group[data-collapsible='icon'] .lecturer-nav-button,
1299
+ .group[data-collapsible='icon'] .lecturer-user-button,
1300
+ .group[data-collapsible='icon'] .lecturer-logout-button {
1301
+ @apply mx-auto flex items-center justify-center rounded-[9px];
1302
+ width: 32px !important;
1303
+ height: 32px !important;
1304
+ padding: 0 !important;
1305
+ }
1306
+
1307
+ .group[data-collapsible='icon'] .lecturer-sidebar-footer {
1308
+ @apply px-3;
1309
+ }
1310
+
1311
+ .lecturer-workspace {
1312
+ @apply flex min-w-0 flex-1 flex-col;
1313
+ background-color: var(--lecturer-bg);
1314
+ color: var(--lecturer-text);
1315
+ }
1316
+
1317
+ .lecturer-page {
1318
+ @apply w-full px-5 py-8 sm:px-8 lg:px-10;
1319
+ }
1320
+
1321
+ .lecturer-page-header {
1322
+ @apply flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between;
1323
+ }
1324
+
1325
+ .lecturer-page-header h1 {
1326
+ @apply text-3xl font-bold leading-tight tracking-[-0.03em];
1327
+ color: var(--lecturer-text);
1328
+ }
1329
+
1330
+ .lecturer-page-header p {
1331
+ @apply mt-1 text-sm leading-relaxed;
1332
+ color: var(--lecturer-muted);
1333
+ }
1334
+
1335
+ .lecturer-header-actions {
1336
+ @apply flex items-center gap-3;
1337
+ }
1338
+
1339
+ .lecturer-theme-toggle {
1340
+ @apply static size-9 rounded-[9px] shadow-none sm:static;
1341
+ background-color: var(--lecturer-surface) !important;
1342
+ border-color: var(--lecturer-border-strong) !important;
1343
+ color: var(--lecturer-primary) !important;
1344
+ }
1345
+
1346
+ .lecturer-create-button {
1347
+ @apply h-9 rounded-[8px] px-4 text-xs font-semibold shadow-none;
1348
+ background-color: var(--lecturer-primary) !important;
1349
+ color: var(--lecturer-primary-foreground) !important;
1350
+ }
1351
+
1352
+ .lecturer-cancel-button {
1353
+ @apply h-10 rounded-[8px] px-5 text-sm font-semibold shadow-none;
1354
+ background-color: var(--lecturer-surface) !important;
1355
+ border-color: var(--lecturer-border) !important;
1356
+ color: var(--lecturer-text) !important;
1357
+ }
1358
+
1359
+ .lecturer-cancel-button:hover {
1360
+ background-color: var(--lecturer-surface-soft) !important;
1361
+ }
1362
+
1363
+ .lecturer-course-dialog {
1364
+ @apply gap-6 rounded-[16px] border p-6 shadow-none sm:max-w-[460px];
1365
+ background-color: var(--lecturer-surface);
1366
+ border-color: var(--lecturer-border-strong);
1367
+ box-shadow: var(--lecturer-shadow);
1368
+ color: var(--lecturer-text);
1369
+ }
1370
+
1371
+ .lecturer-course-form {
1372
+ @apply flex flex-col gap-5;
1373
+ }
1374
+
1375
+ .lecturer-form-field {
1376
+ @apply flex flex-col gap-2;
1377
+ }
1378
+
1379
+ .lecturer-form-label {
1380
+ color: var(--lecturer-text);
1381
+ }
1382
+
1383
+ .lecturer-form-input,
1384
+ .lecturer-form-textarea {
1385
+ @apply w-full rounded-[8px] border px-4 text-sm shadow-none outline-none transition-[border-color,box-shadow];
1386
+ background-color: color-mix(
1387
+ in oklab,
1388
+ var(--lecturer-primary) 6%,
1389
+ var(--lecturer-surface)
1390
+ );
1391
+ border-color: var(--lecturer-border);
1392
+ color: var(--lecturer-text);
1393
+ }
1394
+
1395
+ .lecturer-form-input {
1396
+ @apply h-11;
1397
+ }
1398
+
1399
+ .lecturer-form-textarea {
1400
+ @apply min-h-28 resize-none py-3;
1401
+ }
1402
+
1403
+ .lecturer-form-input::placeholder,
1404
+ .lecturer-form-textarea::placeholder {
1405
+ color: var(--app-placeholder);
1406
+ }
1407
+
1408
+ .lecturer-form-input:focus,
1409
+ .lecturer-form-textarea:focus {
1410
+ border-color: var(--lecturer-primary);
1411
+ box-shadow: 0 0 0 3px
1412
+ color-mix(in oklab, var(--lecturer-primary) 18%, transparent);
1413
+ }
1414
+
1415
+ .lecturer-course-form .lecturer-status {
1416
+ @apply mx-0 mt-0;
1417
+ }
1418
+
1419
+ .lecturer-course-footer {
1420
+ @apply gap-3 pt-1;
1421
+ }
1422
+
1423
+ .lecturer-metric-grid {
1424
+ @apply mt-8 grid grid-cols-1 gap-5 md:grid-cols-3;
1425
+ }
1426
+
1427
+ .lecturer-metric-card {
1428
+ @apply gap-0 rounded-[14px] border py-0 shadow-none;
1429
+ background-color: var(--lecturer-surface);
1430
+ border-color: var(--lecturer-border-soft);
1431
+ box-shadow: var(--lecturer-shadow);
1432
+ }
1433
+
1434
+ .lecturer-metric-content {
1435
+ @apply flex items-center gap-5 px-6 py-6;
1436
+ }
1437
+
1438
+ .lecturer-metric-icon {
1439
+ @apply flex size-12 shrink-0 items-center justify-center rounded-[12px];
1440
+ background-color: var(--lecturer-primary-soft);
1441
+ color: var(--lecturer-primary);
1442
+ }
1443
+
1444
+ .lecturer-metric-icon[data-tone='danger'] {
1445
+ background-color: color-mix(
1446
+ in oklab,
1447
+ var(--lecturer-danger) 12%,
1448
+ var(--lecturer-surface)
1449
+ );
1450
+ color: var(--lecturer-danger);
1451
+ }
1452
+
1453
+ .lecturer-metric-copy p {
1454
+ @apply text-sm;
1455
+ color: var(--lecturer-muted);
1456
+ }
1457
+
1458
+ .lecturer-metric-value-row {
1459
+ @apply mt-1 flex items-end gap-2;
1460
+ }
1461
+
1462
+ .lecturer-metric-value-row strong {
1463
+ @apply text-3xl font-bold leading-none;
1464
+ color: var(--lecturer-text);
1465
+ }
1466
+
1467
+ .lecturer-metric-icon[data-tone='danger']
1468
+ + .lecturer-metric-copy
1469
+ .lecturer-metric-value-row
1470
+ strong {
1471
+ color: var(--lecturer-danger);
1472
+ }
1473
+
1474
+ .lecturer-metric-value-row span {
1475
+ @apply pb-1 text-xs;
1476
+ color: var(--lecturer-muted);
1477
+ }
1478
+
1479
+ .lecturer-table-card {
1480
+ @apply mt-8 gap-0 overflow-hidden rounded-[14px] border py-0 shadow-none;
1481
+ background-color: var(--lecturer-surface);
1482
+ border-color: var(--lecturer-border);
1483
+ box-shadow: var(--lecturer-shadow);
1484
+ }
1485
+
1486
+ .lecturer-table-content {
1487
+ @apply px-0;
1488
+ }
1489
+
1490
+ .lecturer-table-heading {
1491
+ @apply flex flex-col gap-4 border-b px-6 py-5 sm:flex-row sm:items-center sm:justify-between;
1492
+ border-color: var(--lecturer-border);
1493
+ }
1494
+
1495
+ .lecturer-table-heading h2 {
1496
+ @apply text-base font-semibold;
1497
+ color: var(--lecturer-text);
1498
+ }
1499
+
1500
+ .lecturer-search-field {
1501
+ @apply relative w-full sm:max-w-[320px];
1502
+ }
1503
+
1504
+ .lecturer-search-field svg {
1505
+ @apply pointer-events-none absolute left-3 top-1/2 -translate-y-1/2;
1506
+ color: var(--lecturer-muted);
1507
+ z-index: 1;
1508
+ }
1509
+
1510
+ .lecturer-search-input {
1511
+ @apply h-10 rounded-[9px] border text-sm shadow-none outline-none transition-[border-color,box-shadow];
1512
+ background-color: color-mix(
1513
+ in oklab,
1514
+ var(--lecturer-primary) 5%,
1515
+ var(--lecturer-surface)
1516
+ );
1517
+ border-color: var(--lecturer-border);
1518
+ color: var(--lecturer-text);
1519
+ padding-right: 0.75rem !important;
1520
+ padding-left: 2.5rem !important;
1521
+ }
1522
+
1523
+ .lecturer-search-input::placeholder {
1524
+ color: var(--app-placeholder);
1525
+ }
1526
+
1527
+ .lecturer-search-input:focus {
1528
+ border-color: var(--lecturer-primary);
1529
+ box-shadow: 0 0 0 3px
1530
+ color-mix(in oklab, var(--lecturer-primary) 16%, transparent);
1531
+ }
1532
+
1533
+ .lecturer-status {
1534
+ @apply mx-6 mt-4 rounded-[10px] border px-4 py-3 text-sm font-medium;
1535
+ background-color: color-mix(
1536
+ in oklab,
1537
+ var(--lecturer-danger) 8%,
1538
+ var(--lecturer-surface)
1539
+ );
1540
+ border-color: color-mix(
1541
+ in oklab,
1542
+ var(--lecturer-danger) 24%,
1543
+ transparent
1544
+ );
1545
+ color: var(--lecturer-danger);
1546
+ }
1547
+
1548
+ .lecturer-table-wrap {
1549
+ @apply w-full overflow-x-auto;
1550
+ }
1551
+
1552
+ .lecturer-table {
1553
+ @apply w-full min-w-[620px] border-collapse text-left;
1554
+ }
1555
+
1556
+ .lecturer-table th {
1557
+ @apply px-6 py-4 text-xs font-semibold;
1558
+ color: var(--lecturer-muted);
1559
+ }
1560
+
1561
+ .lecturer-table td {
1562
+ @apply border-t px-6 py-4 text-sm;
1563
+ border-color: var(--lecturer-border);
1564
+ color: var(--lecturer-muted);
1565
+ }
1566
+
1567
+ .lecturer-table tbody tr:hover {
1568
+ background-color: color-mix(
1569
+ in oklab,
1570
+ var(--lecturer-primary) 5%,
1571
+ transparent
1572
+ );
1573
+ }
1574
+
1575
+ .lecturer-kb-name {
1576
+ @apply block max-w-[260px] font-semibold;
1577
+ color: var(--lecturer-text);
1578
+ }
1579
+
1580
+ .lecturer-doc-pill {
1581
+ @apply inline-flex items-center gap-1.5 rounded-[7px] px-2.5 py-1 text-xs font-semibold;
1582
+ background-color: var(--lecturer-surface-soft);
1583
+ color: var(--lecturer-text);
1584
+ }
1585
+
1586
+ .lecturer-doc-pill svg {
1587
+ color: var(--lecturer-primary);
1588
+ }
1589
+
1590
+ .lecturer-doc-error {
1591
+ @apply size-1.5 rounded-full;
1592
+ background-color: var(--lecturer-danger);
1593
+ }
1594
+
1595
+ .lecturer-table-empty {
1596
+ @apply py-10 text-center text-sm;
1597
+ color: var(--lecturer-muted) !important;
1598
+ }
1599
+
1600
+ .lecturer-manage-button {
1601
+ @apply inline-flex items-center justify-center gap-1 rounded-[7px] px-3 py-1.5 text-sm font-semibold outline-none transition-colors focus-visible:ring-2;
1602
+ background-color: color-mix(
1603
+ in oklab,
1604
+ var(--lecturer-primary) 10%,
1605
+ var(--lecturer-surface)
1606
+ );
1607
+ color: var(--lecturer-primary);
1608
+ --tw-ring-color: color-mix(
1609
+ in oklab,
1610
+ var(--lecturer-primary) 45%,
1611
+ transparent
1612
+ );
1613
+ }
1614
+
1615
+ .lecturer-manage-button:hover {
1616
+ background-color: color-mix(
1617
+ in oklab,
1618
+ var(--lecturer-primary) 16%,
1619
+ var(--lecturer-surface)
1620
+ );
1621
+ }
1622
+
1623
+ .lecturer-detail-page {
1624
+ @apply min-h-svh w-full;
1625
+ background-color: var(--lecturer-bg);
1626
+ color: var(--lecturer-text);
1627
+ }
1628
+
1629
+ .lecturer-detail-header {
1630
+ @apply flex flex-col gap-5 px-5 py-8 sm:flex-row sm:items-start sm:px-8 lg:px-10;
1631
+ background-color: var(--lecturer-surface-soft);
1632
+ border-bottom: 1px solid var(--lecturer-border-soft);
1633
+ }
1634
+
1635
+ .lecturer-detail-back {
1636
+ @apply mt-1 inline-flex size-9 shrink-0 items-center justify-center rounded-full outline-none transition-colors focus-visible:ring-2;
1637
+ color: var(--lecturer-muted);
1638
+ --tw-ring-color: color-mix(
1639
+ in oklab,
1640
+ var(--lecturer-primary) 45%,
1641
+ transparent
1642
+ );
1643
+ }
1644
+
1645
+ .lecturer-detail-back:hover {
1646
+ background-color: var(--lecturer-surface);
1647
+ color: var(--lecturer-text);
1648
+ }
1649
+
1650
+ .lecturer-detail-heading {
1651
+ @apply min-w-0 flex-1;
1652
+ }
1653
+
1654
+ .lecturer-detail-title-row {
1655
+ @apply flex flex-wrap items-center gap-4;
1656
+ }
1657
+
1658
+ .lecturer-detail-title-row h1 {
1659
+ @apply text-3xl font-bold leading-tight tracking-[-0.03em];
1660
+ color: var(--lecturer-text);
1661
+ }
1662
+
1663
+ .lecturer-detail-heading p {
1664
+ @apply mt-1 text-base italic leading-relaxed;
1665
+ color: var(--lecturer-muted);
1666
+ }
1667
+
1668
+ .lecturer-detail-actions {
1669
+ @apply flex items-center gap-3 sm:ml-auto;
1670
+ }
1671
+
1672
+ .lecturer-edit-button {
1673
+ @apply h-12 rounded-[8px] px-5 text-base font-semibold shadow-none;
1674
+ background-color: var(--lecturer-surface) !important;
1675
+ border-color: var(--lecturer-border) !important;
1676
+ color: var(--lecturer-text) !important;
1677
+ }
1678
+
1679
+ .lecturer-edit-button:hover {
1680
+ background-color: color-mix(
1681
+ in oklab,
1682
+ var(--lecturer-primary) 8%,
1683
+ var(--lecturer-surface)
1684
+ ) !important;
1685
+ }
1686
+
1687
+ .lecturer-delete-button {
1688
+ @apply size-12 rounded-[8px] shadow-none;
1689
+ background-color: color-mix(
1690
+ in oklab,
1691
+ var(--lecturer-danger) 10%,
1692
+ var(--lecturer-surface)
1693
+ ) !important;
1694
+ color: var(--lecturer-danger) !important;
1695
+ }
1696
+
1697
+ .lecturer-delete-button:hover {
1698
+ background-color: color-mix(
1699
+ in oklab,
1700
+ var(--lecturer-danger) 16%,
1701
+ var(--lecturer-surface)
1702
+ ) !important;
1703
+ }
1704
+
1705
+ .lecturer-delete-copy {
1706
+ @apply text-sm leading-relaxed;
1707
+ color: var(--lecturer-muted);
1708
+ }
1709
+
1710
+ .lecturer-delete-copy strong {
1711
+ color: var(--lecturer-text);
1712
+ }
1713
+
1714
+ .lecturer-danger-action-button {
1715
+ @apply h-10 rounded-[8px] px-5 text-sm font-semibold shadow-none;
1716
+ background-color: var(--lecturer-danger) !important;
1717
+ color: var(--lecturer-primary-foreground) !important;
1718
+ }
1719
+
1720
+ .lecturer-danger-action-button:hover {
1721
+ background-color: color-mix(
1722
+ in oklab,
1723
+ var(--lecturer-danger) 86%,
1724
+ #000
1725
+ ) !important;
1726
+ }
1727
+
1728
+ .lecturer-detail-content {
1729
+ @apply mx-auto flex w-full max-w-[920px] flex-col gap-6 px-5 py-10 sm:px-8;
1730
+ }
1731
+
1732
+ .lecturer-upload-input {
1733
+ @apply sr-only;
1734
+ }
1735
+
1736
+ .lecturer-upload-card {
1737
+ @apply flex min-h-[185px] w-full flex-col items-center justify-center gap-3 rounded-[14px] border border-dashed px-6 py-8 text-center outline-none transition-colors focus-visible:ring-2;
1738
+ background-color: color-mix(
1739
+ in oklab,
1740
+ var(--lecturer-primary) 6%,
1741
+ var(--lecturer-surface)
1742
+ );
1743
+ border-color: color-mix(
1744
+ in oklab,
1745
+ var(--lecturer-primary) 28%,
1746
+ var(--lecturer-border)
1747
+ );
1748
+ color: var(--lecturer-text);
1749
+ --tw-ring-color: color-mix(
1750
+ in oklab,
1751
+ var(--lecturer-primary) 45%,
1752
+ transparent
1753
+ );
1754
+ }
1755
+
1756
+ .lecturer-upload-card:hover {
1757
+ background-color: color-mix(
1758
+ in oklab,
1759
+ var(--lecturer-primary) 10%,
1760
+ var(--lecturer-surface)
1761
+ );
1762
+ border-color: var(--lecturer-primary);
1763
+ }
1764
+
1765
+ .lecturer-upload-card[data-uploading='true'] {
1766
+ opacity: 0.78;
1767
+ }
1768
+
1769
+ .lecturer-upload-icon {
1770
+ @apply flex size-12 items-center justify-center rounded-full;
1771
+ background-color: var(--lecturer-surface);
1772
+ color: var(--lecturer-primary);
1773
+ }
1774
+
1775
+ .lecturer-upload-title {
1776
+ @apply text-base font-medium;
1777
+ color: var(--lecturer-text);
1778
+ }
1779
+
1780
+ .lecturer-upload-description {
1781
+ @apply max-w-[560px] text-sm leading-relaxed;
1782
+ color: var(--lecturer-muted);
1783
+ }
1784
+
1785
+ .lecturer-document-section {
1786
+ @apply mt-1;
1787
+ }
1788
+
1789
+ .lecturer-document-heading {
1790
+ @apply flex items-center gap-2 text-sm font-bold;
1791
+ color: var(--lecturer-text);
1792
+ }
1793
+
1794
+ .lecturer-document-list {
1795
+ @apply mt-5 flex flex-col gap-3;
1796
+ }
1797
+
1798
+ .lecturer-document-card {
1799
+ @apply gap-0 rounded-[10px] border py-0 shadow-none;
1800
+ background-color: color-mix(
1801
+ in oklab,
1802
+ var(--lecturer-primary) 3%,
1803
+ var(--lecturer-surface)
1804
+ );
1805
+ border-color: color-mix(
1806
+ in oklab,
1807
+ var(--lecturer-primary) 18%,
1808
+ var(--lecturer-border)
1809
+ );
1810
+ }
1811
+
1812
+ .lecturer-document-card[data-status='failed'] {
1813
+ background-color: color-mix(
1814
+ in oklab,
1815
+ var(--lecturer-danger) 5%,
1816
+ var(--lecturer-surface)
1817
+ );
1818
+ border-color: color-mix(
1819
+ in oklab,
1820
+ var(--lecturer-danger) 25%,
1821
+ var(--lecturer-border)
1822
+ );
1823
+ }
1824
+
1825
+ .lecturer-document-content {
1826
+ @apply flex items-start gap-4 px-5 py-5;
1827
+ }
1828
+
1829
+ .lecturer-document-icon {
1830
+ @apply flex size-10 shrink-0 items-center justify-center rounded-[8px];
1831
+ background-color: color-mix(
1832
+ in oklab,
1833
+ var(--lecturer-primary) 12%,
1834
+ var(--lecturer-surface)
1835
+ );
1836
+ color: var(--lecturer-primary);
1837
+ }
1838
+
1839
+ .lecturer-document-icon[data-status='failed'] {
1840
+ background-color: color-mix(
1841
+ in oklab,
1842
+ var(--lecturer-danger) 12%,
1843
+ var(--lecturer-surface)
1844
+ );
1845
+ color: var(--lecturer-danger);
1846
+ }
1847
+
1848
+ .lecturer-document-copy {
1849
+ @apply min-w-0 flex-1;
1850
+ }
1851
+
1852
+ .lecturer-document-copy h3 {
1853
+ @apply truncate text-sm font-bold;
1854
+ color: var(--lecturer-text);
1855
+ }
1856
+
1857
+ .lecturer-document-meta {
1858
+ @apply mt-2 flex flex-wrap items-center gap-3 text-xs;
1859
+ color: var(--lecturer-muted);
1860
+ }
1861
+
1862
+ .lecturer-document-status {
1863
+ @apply mt-3 flex items-center gap-1.5 text-xs font-semibold;
1864
+ color: var(--lecturer-success);
1865
+ }
1866
+
1867
+ .lecturer-document-status[data-status='failed'] {
1868
+ color: var(--lecturer-danger);
1869
+ }
1870
+
1871
+ .lecturer-document-status[data-status='processing'] {
1872
+ color: var(--lecturer-primary);
1873
+ }
1874
+
1875
+ .lecturer-document-actions {
1876
+ @apply ml-auto flex shrink-0 items-center gap-3;
1877
+ }
1878
+
1879
+ .lecturer-document-delete {
1880
+ @apply inline-flex size-8 items-center justify-center rounded-[7px] outline-none transition-colors focus-visible:ring-2;
1881
+ color: var(--lecturer-muted);
1882
+ --tw-ring-color: color-mix(
1883
+ in oklab,
1884
+ var(--lecturer-primary) 45%,
1885
+ transparent
1886
+ );
1887
+ }
1888
+
1889
+ .lecturer-document-delete:hover {
1890
+ background-color: color-mix(
1891
+ in oklab,
1892
+ var(--lecturer-danger) 10%,
1893
+ var(--lecturer-surface)
1894
+ );
1895
+ color: var(--lecturer-danger);
1896
+ }
1897
+
1898
+ .lecturer-detail-empty {
1899
+ @apply rounded-[10px] border px-5 py-8 text-center text-sm;
1900
+ background-color: var(--lecturer-surface);
1901
+ border-color: var(--lecturer-border);
1902
+ color: var(--lecturer-muted);
1903
+ }
1904
+
1905
  @media (max-width: 640px) {
1906
  .student-chat-stage--session {
1907
  @apply px-3;
 
1914
  .student-chat-composer {
1915
  @apply pb-4;
1916
  }
1917
+
1918
+ .lecturer-page {
1919
+ @apply px-4 py-6;
1920
+ }
1921
+
1922
+ .lecturer-page-header h1 {
1923
+ @apply text-2xl;
1924
+ }
1925
+
1926
+ .lecturer-header-actions {
1927
+ @apply w-full;
1928
+ }
1929
+
1930
+ .lecturer-create-button {
1931
+ @apply flex-1;
1932
+ }
1933
+
1934
+ .lecturer-detail-title-row h1 {
1935
+ @apply text-2xl;
1936
+ }
1937
+
1938
+ .lecturer-detail-actions {
1939
+ @apply w-full;
1940
+ }
1941
+
1942
+ .lecturer-edit-button {
1943
+ @apply flex-1;
1944
+ }
1945
+
1946
+ .lecturer-detail-content {
1947
+ @apply px-4 py-6;
1948
+ }
1949
+
1950
+ .lecturer-document-content {
1951
+ @apply flex-col;
1952
+ }
1953
+
1954
+ .lecturer-document-actions {
1955
+ @apply ml-0 w-full justify-between;
1956
+ }
1957
  }
1958
  }
resources/js/components/auth/register-role-form.tsx CHANGED
@@ -1,6 +1,7 @@
1
  import { Head, Link } from '@inertiajs/react';
2
  import type { FormEvent } from 'react';
3
  import { useState } from 'react';
 
4
  import AppearanceToggle from '@/components/appearance-toggle';
5
  import { Button } from '@/components/ui/button';
6
  import { Card, CardContent } from '@/components/ui/card';
@@ -30,26 +31,113 @@ type RegisterRoleFormProps = {
30
  role: RegisterRole;
31
  };
32
 
 
 
 
 
 
 
 
 
 
 
33
  type RegisterFieldProps = {
34
  autoComplete?: string;
35
  autoFocus?: boolean;
 
 
36
  label: string;
37
  name: string;
 
 
 
38
  placeholder: string;
39
  required?: boolean;
40
  tabIndex: number;
41
  type?: string;
 
42
  };
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  function RegisterField({
45
  autoComplete,
46
  autoFocus = false,
 
 
47
  label,
48
  name,
 
 
 
49
  placeholder,
50
  required = false,
51
  tabIndex,
52
  type = 'text',
 
53
  }: RegisterFieldProps) {
54
  return (
55
  <div className="register-field">
@@ -62,11 +150,22 @@ function RegisterField({
62
  type={type}
63
  autoComplete={autoComplete}
64
  autoFocus={autoFocus}
 
65
  placeholder={placeholder}
 
 
 
 
66
  required={required}
67
  tabIndex={tabIndex}
 
68
  className="register-input"
69
  />
 
 
 
 
 
70
  </div>
71
  );
72
  }
@@ -75,7 +174,55 @@ export default function RegisterRoleForm({ role }: RegisterRoleFormProps) {
75
  const content = roleContent[role];
76
  const [backendError, setBackendError] = useState<string>();
77
  const [backendStatus, setBackendStatus] = useState<string>();
 
 
 
78
  const [isProcessing, setIsProcessing] = useState(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
79
 
80
  const handleSubmit = async (
81
  event: FormEvent<HTMLFormElement>,
@@ -83,17 +230,27 @@ export default function RegisterRoleForm({ role }: RegisterRoleFormProps) {
83
  event.preventDefault();
84
  setBackendError(undefined);
85
  setBackendStatus(undefined);
86
- setIsProcessing(true);
 
 
 
87
 
88
- const formData = new FormData(event.currentTarget);
 
 
 
 
 
 
 
89
 
90
  try {
91
  const response = await authApi.register({
92
- email: String(formData.get('email') ?? ''),
93
- password: String(formData.get('password') ?? ''),
94
- name: String(formData.get('name') ?? ''),
95
  role: content.apiRole,
96
- identity_number: String(formData.get('identity_number') ?? ''),
97
  });
98
 
99
  setBackendStatus(response.message);
@@ -119,7 +276,11 @@ export default function RegisterRoleForm({ role }: RegisterRoleFormProps) {
119
 
120
  <h1 className="register-heading">{content.heading}</h1>
121
 
122
- <form className="register-form" onSubmit={handleSubmit}>
 
 
 
 
123
  <input type="hidden" name="role" value={role} />
124
 
125
  <RegisterField
@@ -128,15 +289,34 @@ export default function RegisterRoleForm({ role }: RegisterRoleFormProps) {
128
  placeholder="Masukkan nama lengkap"
129
  autoComplete="name"
130
  autoFocus
 
 
 
 
131
  required
132
  tabIndex={1}
 
133
  />
134
  <RegisterField
135
  name="identity_number"
136
  label={content.identifierLabel}
137
  placeholder={content.identifierPlaceholder}
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  required
139
  tabIndex={2}
 
140
  />
141
  <RegisterField
142
  name="email"
@@ -144,8 +324,15 @@ export default function RegisterRoleForm({ role }: RegisterRoleFormProps) {
144
  label="Email"
145
  placeholder="contoh@kampus.ac.id"
146
  autoComplete="email"
 
 
 
 
 
 
147
  required
148
  tabIndex={3}
 
149
  />
150
  <RegisterField
151
  name="password"
@@ -153,8 +340,14 @@ export default function RegisterRoleForm({ role }: RegisterRoleFormProps) {
153
  label="Password"
154
  placeholder="Minimal 6 karakter"
155
  autoComplete="new-password"
 
 
 
 
 
156
  required
157
  tabIndex={4}
 
158
  />
159
 
160
  <Button
 
1
  import { Head, Link } from '@inertiajs/react';
2
  import type { FormEvent } from 'react';
3
  import { useState } from 'react';
4
+
5
  import AppearanceToggle from '@/components/appearance-toggle';
6
  import { Button } from '@/components/ui/button';
7
  import { Card, CardContent } from '@/components/ui/card';
 
31
  role: RegisterRole;
32
  };
33
 
34
+ type RegisterFormValues = {
35
+ email: string;
36
+ identity_number: string;
37
+ name: string;
38
+ password: string;
39
+ };
40
+
41
+ type RegisterFormErrors = Partial<Record<keyof RegisterFormValues, string>>;
42
+ type RegisterTouchedFields = Partial<Record<keyof RegisterFormValues, boolean>>;
43
+
44
  type RegisterFieldProps = {
45
  autoComplete?: string;
46
  autoFocus?: boolean;
47
+ error?: string;
48
+ inputMode?: 'email' | 'numeric' | 'text';
49
  label: string;
50
  name: string;
51
+ onBlur?: () => void;
52
+ onChange: (value: string) => void;
53
+ pattern?: string;
54
  placeholder: string;
55
  required?: boolean;
56
  tabIndex: number;
57
  type?: string;
58
+ value: string;
59
  };
60
 
61
+ const emptyFormValues: RegisterFormValues = {
62
+ email: '',
63
+ identity_number: '',
64
+ name: '',
65
+ password: '',
66
+ };
67
+
68
+ const namePattern = /^[\p{L}\s.'-]+$/u;
69
+ const identityNumberPattern = /^(?:\d{5,30}|[A-Z]{2,10}\d{3,30})$/;
70
+ const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
71
+
72
+ function validateRegisterForm(
73
+ values: RegisterFormValues,
74
+ identifierLabel: string,
75
+ ): RegisterFormErrors {
76
+ const errors: RegisterFormErrors = {};
77
+ const normalizedName = values.name.trim().replace(/\s+/g, ' ');
78
+ const normalizedEmail = values.email.trim();
79
+ const normalizedIdentityNumber = values.identity_number.trim();
80
+
81
+ if (!normalizedName) {
82
+ errors.name = 'Nama lengkap wajib diisi.';
83
+ } else if (normalizedName.length < 3) {
84
+ errors.name = 'Nama lengkap minimal 3 karakter.';
85
+ } else if (!namePattern.test(normalizedName)) {
86
+ errors.name = 'Nama hanya boleh berisi huruf dan tanda nama.';
87
+ }
88
+
89
+ if (!normalizedIdentityNumber) {
90
+ errors.identity_number = `${identifierLabel} wajib diisi.`;
91
+ } else if (!identityNumberPattern.test(normalizedIdentityNumber)) {
92
+ errors.identity_number = `${identifierLabel} hanya boleh angka, atau kode huruf besar diikuti angka.`;
93
+ }
94
+
95
+ if (!normalizedEmail) {
96
+ errors.email = 'Email wajib diisi.';
97
+ } else if (
98
+ normalizedEmail.includes(' ') ||
99
+ !emailPattern.test(normalizedEmail)
100
+ ) {
101
+ errors.email = 'Format email tidak valid.';
102
+ }
103
+
104
+ if (!values.password) {
105
+ errors.password = 'Password wajib diisi.';
106
+ } else if (values.password.length < 6) {
107
+ errors.password = 'Password minimal 6 karakter.';
108
+ } else if (/\s/.test(values.password)) {
109
+ errors.password = 'Password tidak boleh mengandung spasi.';
110
+ }
111
+
112
+ return errors;
113
+ }
114
+
115
+ function normalizeRegisterValues(
116
+ values: RegisterFormValues,
117
+ ): RegisterFormValues {
118
+ return {
119
+ email: values.email.trim(),
120
+ identity_number: values.identity_number.trim(),
121
+ name: values.name.trim().replace(/\s+/g, ' '),
122
+ password: values.password,
123
+ };
124
+ }
125
+
126
  function RegisterField({
127
  autoComplete,
128
  autoFocus = false,
129
+ error,
130
+ inputMode = 'text',
131
  label,
132
  name,
133
+ onBlur,
134
+ onChange,
135
+ pattern,
136
  placeholder,
137
  required = false,
138
  tabIndex,
139
  type = 'text',
140
+ value,
141
  }: RegisterFieldProps) {
142
  return (
143
  <div className="register-field">
 
150
  type={type}
151
  autoComplete={autoComplete}
152
  autoFocus={autoFocus}
153
+ aria-invalid={Boolean(error)}
154
  placeholder={placeholder}
155
+ inputMode={inputMode}
156
+ onBlur={onBlur}
157
+ onChange={(event) => onChange(event.target.value)}
158
+ pattern={pattern}
159
  required={required}
160
  tabIndex={tabIndex}
161
+ value={value}
162
  className="register-input"
163
  />
164
+ {error && (
165
+ <p className="register-error" role="alert">
166
+ {error}
167
+ </p>
168
+ )}
169
  </div>
170
  );
171
  }
 
174
  const content = roleContent[role];
175
  const [backendError, setBackendError] = useState<string>();
176
  const [backendStatus, setBackendStatus] = useState<string>();
177
+ const [fieldErrors, setFieldErrors] = useState<RegisterFormErrors>({});
178
+ const [formValues, setFormValues] =
179
+ useState<RegisterFormValues>(emptyFormValues);
180
  const [isProcessing, setIsProcessing] = useState(false);
181
+ const [submittedOnce, setSubmittedOnce] = useState(false);
182
+ const [touchedFields, setTouchedFields] = useState<RegisterTouchedFields>(
183
+ {},
184
+ );
185
+
186
+ function validateCurrentForm(
187
+ values: RegisterFormValues = formValues,
188
+ ): RegisterFormErrors {
189
+ return validateRegisterForm(values, content.identifierLabel);
190
+ }
191
+
192
+ function setSingleFieldError(
193
+ field: keyof RegisterFormValues,
194
+ errors: RegisterFormErrors,
195
+ ): void {
196
+ setFieldErrors((currentErrors) => {
197
+ const nextErrors = { ...currentErrors };
198
+ const message = errors[field];
199
+
200
+ if (message) {
201
+ nextErrors[field] = message;
202
+ } else {
203
+ delete nextErrors[field];
204
+ }
205
+
206
+ return nextErrors;
207
+ });
208
+ }
209
+
210
+ function updateField(field: keyof RegisterFormValues, value: string): void {
211
+ const nextValues = { ...formValues, [field]: value };
212
+ setFormValues(nextValues);
213
+
214
+ if (submittedOnce || touchedFields[field]) {
215
+ setSingleFieldError(field, validateCurrentForm(nextValues));
216
+ }
217
+ }
218
+
219
+ function validateFieldOnBlur(field: keyof RegisterFormValues): void {
220
+ setTouchedFields((currentFields) => ({
221
+ ...currentFields,
222
+ [field]: true,
223
+ }));
224
+ setSingleFieldError(field, validateCurrentForm());
225
+ }
226
 
227
  const handleSubmit = async (
228
  event: FormEvent<HTMLFormElement>,
 
230
  event.preventDefault();
231
  setBackendError(undefined);
232
  setBackendStatus(undefined);
233
+ setSubmittedOnce(true);
234
+
235
+ const normalizedValues = normalizeRegisterValues(formValues);
236
+ const nextFieldErrors = validateCurrentForm(normalizedValues);
237
 
238
+ setFormValues(normalizedValues);
239
+ setFieldErrors(nextFieldErrors);
240
+
241
+ if (Object.keys(nextFieldErrors).length > 0) {
242
+ return;
243
+ }
244
+
245
+ setIsProcessing(true);
246
 
247
  try {
248
  const response = await authApi.register({
249
+ email: normalizedValues.email,
250
+ password: normalizedValues.password,
251
+ name: normalizedValues.name,
252
  role: content.apiRole,
253
+ identity_number: normalizedValues.identity_number,
254
  });
255
 
256
  setBackendStatus(response.message);
 
276
 
277
  <h1 className="register-heading">{content.heading}</h1>
278
 
279
+ <form
280
+ className="register-form"
281
+ noValidate
282
+ onSubmit={handleSubmit}
283
+ >
284
  <input type="hidden" name="role" value={role} />
285
 
286
  <RegisterField
 
289
  placeholder="Masukkan nama lengkap"
290
  autoComplete="name"
291
  autoFocus
292
+ error={fieldErrors.name}
293
+ onBlur={() => validateFieldOnBlur('name')}
294
+ onChange={(value) => updateField('name', value)}
295
+ pattern="[\p{L}\s.'-]+"
296
  required
297
  tabIndex={1}
298
+ value={formValues.name}
299
  />
300
  <RegisterField
301
  name="identity_number"
302
  label={content.identifierLabel}
303
  placeholder={content.identifierPlaceholder}
304
+ autoComplete="off"
305
+ error={fieldErrors.identity_number}
306
+ inputMode="text"
307
+ onBlur={() =>
308
+ validateFieldOnBlur('identity_number')
309
+ }
310
+ onChange={(value) =>
311
+ updateField(
312
+ 'identity_number',
313
+ value.toUpperCase(),
314
+ )
315
+ }
316
+ pattern="(?:\d{5,30}|[A-Z]{2,10}\d{3,30})"
317
  required
318
  tabIndex={2}
319
+ value={formValues.identity_number}
320
  />
321
  <RegisterField
322
  name="email"
 
324
  label="Email"
325
  placeholder="contoh@kampus.ac.id"
326
  autoComplete="email"
327
+ error={fieldErrors.email}
328
+ inputMode="email"
329
+ onBlur={() => validateFieldOnBlur('email')}
330
+ onChange={(value) =>
331
+ updateField('email', value)
332
+ }
333
  required
334
  tabIndex={3}
335
+ value={formValues.email}
336
  />
337
  <RegisterField
338
  name="password"
 
340
  label="Password"
341
  placeholder="Minimal 6 karakter"
342
  autoComplete="new-password"
343
+ error={fieldErrors.password}
344
+ onBlur={() => validateFieldOnBlur('password')}
345
+ onChange={(value) =>
346
+ updateField('password', value)
347
+ }
348
  required
349
  tabIndex={4}
350
+ value={formValues.password}
351
  />
352
 
353
  <Button
resources/js/components/dosen/dosen-shell.tsx ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { router } from '@inertiajs/react';
2
+ import { LayoutDashboard, LogOut, MessageSquare, Settings } from 'lucide-react';
3
+ import type { CSSProperties, ReactNode } from 'react';
4
+ import { useSyncExternalStore } from 'react';
5
+
6
+ import { Avatar, AvatarFallback } from '@/components/ui/avatar';
7
+ import {
8
+ Sidebar,
9
+ SidebarContent,
10
+ SidebarFooter,
11
+ SidebarGroup,
12
+ SidebarGroupContent,
13
+ SidebarHeader,
14
+ SidebarInset,
15
+ SidebarMenu,
16
+ SidebarMenuButton,
17
+ SidebarMenuItem,
18
+ SidebarProvider,
19
+ SidebarRail,
20
+ SidebarTrigger,
21
+ useSidebar,
22
+ } from '@/components/ui/sidebar';
23
+ import { clearAuthSession, getStoredAuthUser } from '@/lib/auth';
24
+ import { dosen, login } from '@/routes';
25
+ import { pengaturan as dosenPengaturan } from '@/routes/dosen';
26
+
27
+ type DosenMenu = 'dashboard' | 'settings';
28
+
29
+ const lecturerSidebarStyle = {
30
+ '--sidebar-width': '212px',
31
+ '--sidebar-width-icon': '64px',
32
+ } as CSSProperties;
33
+
34
+ function getStoredLecturerName(): string {
35
+ return getStoredAuthUser()?.name ?? 'Dosen';
36
+ }
37
+
38
+ function subscribeToAuthStorage(onStoreChange: () => void): () => void {
39
+ if (typeof window === 'undefined') {
40
+ return () => {};
41
+ }
42
+
43
+ window.addEventListener('storage', onStoreChange);
44
+
45
+ return () => window.removeEventListener('storage', onStoreChange);
46
+ }
47
+
48
+ function useStoredLecturerName(): string {
49
+ return useSyncExternalStore(
50
+ subscribeToAuthStorage,
51
+ getStoredLecturerName,
52
+ () => 'Dosen',
53
+ );
54
+ }
55
+
56
+ function getInitials(name: string): string {
57
+ return name
58
+ .split(' ')
59
+ .filter(Boolean)
60
+ .slice(0, 2)
61
+ .map((part) => part[0]?.toUpperCase())
62
+ .join('');
63
+ }
64
+
65
+ function handleLogout(): void {
66
+ clearAuthSession();
67
+ router.visit(login());
68
+ }
69
+
70
+ function DosenSidebarBrand() {
71
+ const { state, toggleSidebar } = useSidebar();
72
+
73
+ const handleBrandClick = (): void => {
74
+ if (state === 'collapsed') {
75
+ toggleSidebar();
76
+ }
77
+ };
78
+
79
+ return (
80
+ <button
81
+ aria-label="Buka sidebar"
82
+ className="lecturer-brand"
83
+ onClick={handleBrandClick}
84
+ type="button"
85
+ >
86
+ <span className="lecturer-brand-mark">
87
+ <MessageSquare className="size-4" />
88
+ </span>
89
+ <span className="lecturer-brand-copy">RAG Hub</span>
90
+ </button>
91
+ );
92
+ }
93
+
94
+ function DosenSidebar({ activeMenu }: { activeMenu: DosenMenu }) {
95
+ const lecturerName = useStoredLecturerName();
96
+
97
+ return (
98
+ <Sidebar className="lecturer-sidebar" collapsible="icon">
99
+ <SidebarHeader className="lecturer-sidebar-header">
100
+ <DosenSidebarBrand />
101
+
102
+ <SidebarTrigger className="lecturer-sidebar-trigger" />
103
+ </SidebarHeader>
104
+
105
+ <SidebarContent className="lecturer-sidebar-content">
106
+ <SidebarGroup className="lecturer-nav-group">
107
+ <SidebarGroupContent>
108
+ <SidebarMenu className="lecturer-nav-list">
109
+ <SidebarMenuItem>
110
+ <SidebarMenuButton
111
+ className="lecturer-nav-button"
112
+ isActive={activeMenu === 'dashboard'}
113
+ onClick={() => router.visit(dosen())}
114
+ tooltip="Dashboard KB"
115
+ >
116
+ <LayoutDashboard className="size-4" />
117
+ <span>Dashboard KB</span>
118
+ </SidebarMenuButton>
119
+ </SidebarMenuItem>
120
+
121
+ <SidebarMenuItem>
122
+ <SidebarMenuButton
123
+ className="lecturer-nav-button"
124
+ isActive={activeMenu === 'settings'}
125
+ onClick={() =>
126
+ router.visit(dosenPengaturan())
127
+ }
128
+ tooltip="Pengaturan"
129
+ >
130
+ <Settings className="size-4" />
131
+ <span>Pengaturan</span>
132
+ </SidebarMenuButton>
133
+ </SidebarMenuItem>
134
+ </SidebarMenu>
135
+ </SidebarGroupContent>
136
+ </SidebarGroup>
137
+ </SidebarContent>
138
+
139
+ <SidebarFooter className="lecturer-sidebar-footer">
140
+ <SidebarMenu>
141
+ <SidebarMenuItem>
142
+ <SidebarMenuButton
143
+ className="lecturer-user-button"
144
+ tooltip={lecturerName}
145
+ >
146
+ <Avatar className="lecturer-user-avatar">
147
+ <AvatarFallback>
148
+ {getInitials(lecturerName) || 'DS'}
149
+ </AvatarFallback>
150
+ </Avatar>
151
+ <span className="lecturer-user-copy">
152
+ <span className="lecturer-user-name">
153
+ {lecturerName}
154
+ </span>
155
+ <span className="lecturer-user-role">
156
+ Dosen
157
+ </span>
158
+ </span>
159
+ </SidebarMenuButton>
160
+ </SidebarMenuItem>
161
+
162
+ <SidebarMenuItem>
163
+ <SidebarMenuButton
164
+ className="lecturer-logout-button"
165
+ onClick={handleLogout}
166
+ tooltip="Logout"
167
+ >
168
+ <LogOut className="size-4" />
169
+ <span>Logout</span>
170
+ </SidebarMenuButton>
171
+ </SidebarMenuItem>
172
+ </SidebarMenu>
173
+ </SidebarFooter>
174
+
175
+ <SidebarRail />
176
+ </Sidebar>
177
+ );
178
+ }
179
+
180
+ export function DosenShell({
181
+ activeMenu = 'dashboard',
182
+ children,
183
+ }: {
184
+ activeMenu?: DosenMenu;
185
+ children: ReactNode;
186
+ }) {
187
+ return (
188
+ <SidebarProvider
189
+ className="lecturer-dashboard-shell"
190
+ defaultOpen
191
+ style={lecturerSidebarStyle}
192
+ >
193
+ <DosenSidebar activeMenu={activeMenu} />
194
+
195
+ <SidebarInset className="lecturer-workspace">
196
+ {children}
197
+ </SidebarInset>
198
+ </SidebarProvider>
199
+ );
200
+ }
resources/js/components/student/question-composer.tsx CHANGED
@@ -1,5 +1,5 @@
1
  import { BookOpen, Send, Sparkles } from 'lucide-react';
2
- import type { FormEvent } from 'react';
3
 
4
  import { Button } from '@/components/ui/button';
5
  import { Card, CardContent } from '@/components/ui/card';
@@ -15,7 +15,6 @@ import { cn } from '@/lib/utils';
15
 
16
  export function QuestionComposer({
17
  courses,
18
- isCourseLocked = false,
19
  isLoadingCourses,
20
  isProcessing,
21
  onCourseChange,
@@ -26,7 +25,6 @@ export function QuestionComposer({
26
  variant = 'empty',
27
  }: {
28
  courses: CourseResponse[];
29
- isCourseLocked?: boolean;
30
  isLoadingCourses: boolean;
31
  isProcessing: boolean;
32
  onCourseChange: (courseId: string) => void;
@@ -36,6 +34,29 @@ export function QuestionComposer({
36
  selectedCourseId: string;
37
  variant?: 'dock' | 'empty';
38
  }) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  return (
40
  <Card
41
  className={cn(
@@ -52,11 +73,7 @@ export function QuestionComposer({
52
  </span>
53
 
54
  <Select
55
- disabled={
56
- isCourseLocked ||
57
- isLoadingCourses ||
58
- courses.length === 0
59
- }
60
  onValueChange={onCourseChange}
61
  value={selectedCourseId}
62
  >
@@ -86,6 +103,7 @@ export function QuestionComposer({
86
  <textarea
87
  aria-label="Pertanyaan"
88
  className="text-base"
 
89
  onChange={(event) =>
90
  onQuestionChange(event.target.value)
91
  }
@@ -96,11 +114,7 @@ export function QuestionComposer({
96
 
97
  <Button
98
  className="student-floating-send"
99
- disabled={
100
- isProcessing ||
101
- selectedCourseId.length === 0 ||
102
- question.trim().length === 0
103
- }
104
  size="icon"
105
  type="submit"
106
  >
 
1
  import { BookOpen, Send, Sparkles } from 'lucide-react';
2
+ import type { FormEvent, KeyboardEvent } from 'react';
3
 
4
  import { Button } from '@/components/ui/button';
5
  import { Card, CardContent } from '@/components/ui/card';
 
15
 
16
  export function QuestionComposer({
17
  courses,
 
18
  isLoadingCourses,
19
  isProcessing,
20
  onCourseChange,
 
25
  variant = 'empty',
26
  }: {
27
  courses: CourseResponse[];
 
28
  isLoadingCourses: boolean;
29
  isProcessing: boolean;
30
  onCourseChange: (courseId: string) => void;
 
34
  selectedCourseId: string;
35
  variant?: 'dock' | 'empty';
36
  }) {
37
+ const canSubmit =
38
+ !isProcessing &&
39
+ selectedCourseId.length > 0 &&
40
+ question.trim().length > 0;
41
+
42
+ function handleQuestionKeyDown(
43
+ event: KeyboardEvent<HTMLTextAreaElement>,
44
+ ): void {
45
+ if (
46
+ event.key !== 'Enter' ||
47
+ event.shiftKey ||
48
+ event.nativeEvent.isComposing
49
+ ) {
50
+ return;
51
+ }
52
+
53
+ event.preventDefault();
54
+
55
+ if (canSubmit) {
56
+ event.currentTarget.form?.requestSubmit();
57
+ }
58
+ }
59
+
60
  return (
61
  <Card
62
  className={cn(
 
73
  </span>
74
 
75
  <Select
76
+ disabled={isLoadingCourses || courses.length === 0}
 
 
 
 
77
  onValueChange={onCourseChange}
78
  value={selectedCourseId}
79
  >
 
103
  <textarea
104
  aria-label="Pertanyaan"
105
  className="text-base"
106
+ onKeyDown={handleQuestionKeyDown}
107
  onChange={(event) =>
108
  onQuestionChange(event.target.value)
109
  }
 
114
 
115
  <Button
116
  className="student-floating-send"
117
+ disabled={!canSubmit}
 
 
 
 
118
  size="icon"
119
  type="submit"
120
  >
resources/js/components/student/student-shell.tsx CHANGED
@@ -12,6 +12,14 @@ import { useState, useSyncExternalStore } from 'react';
12
  import AppearanceToggle from '@/components/appearance-toggle';
13
  import { Avatar, AvatarFallback } from '@/components/ui/avatar';
14
  import { Button } from '@/components/ui/button';
 
 
 
 
 
 
 
 
15
  import {
16
  Sidebar,
17
  SidebarContent,
@@ -39,6 +47,11 @@ const sidebarStyle = {
39
  '--sidebar-width-icon': '64px',
40
  } as CSSProperties;
41
 
 
 
 
 
 
42
  export function getStoredStudentName(): string {
43
  return getStoredAuthUser()?.name ?? 'Mahasiswa';
44
  }
@@ -131,188 +144,289 @@ function StudentSidebar({
131
  activeSessionId?: string;
132
  chatSessions: ChatSessionResponse[];
133
  isLoading: boolean;
134
- onDeleteSession?: (sessionId: string) => void;
 
 
135
  studentName: string;
136
  }) {
137
  const [isHistoryOpen, setIsHistoryOpen] = useState(true);
 
 
 
 
138
  const { state } = useSidebar();
139
  const isSidebarCollapsed = state === 'collapsed';
140
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
  return (
142
- <Sidebar className="student-sidebar" collapsible="icon">
143
- <SidebarHeader className="student-sidebar-top">
144
- <StudentSidebarLogo />
145
- <SidebarTrigger className="student-sidebar-trigger" />
146
- </SidebarHeader>
147
-
148
- <SidebarContent className="student-sidebar-content">
149
- <SidebarGroup className="student-history-group">
150
- {isSidebarCollapsed ? (
151
- <SidebarGroupContent>
152
- <SidebarMenu className="student-history-list">
153
- <SidebarMenuItem>
154
- <SidebarMenuButton
155
- className="student-history-menu-button"
156
- tooltip="Riwayat Chat"
157
- >
158
- <Clock3 className="student-history-menu-icon size-4" />
159
- <span className="student-history-copy">
160
- <span className="student-history-title">
161
- Riwayat Chat
 
 
162
  </span>
163
- </span>
164
- </SidebarMenuButton>
165
- </SidebarMenuItem>
166
- </SidebarMenu>
167
- </SidebarGroupContent>
168
- ) : (
169
- <>
170
- <SidebarGroupLabel asChild>
171
- <button
172
- aria-expanded={isHistoryOpen}
173
- className="student-history-label"
174
- onClick={() =>
175
- setIsHistoryOpen(
176
- (currentState) => !currentState,
177
- )
178
- }
179
- type="button"
180
- >
181
- <Clock3 className="size-4" />
182
- <span>Riwayat Chat</span>
183
- <ChevronDown className="student-history-chevron" />
184
- </button>
185
- </SidebarGroupLabel>
186
-
187
- {isHistoryOpen && (
188
- <SidebarGroupContent>
189
- <SidebarMenu className="student-history-list">
190
- {isLoading && (
191
- <SidebarMenuItem>
192
- <SidebarMenuButton
193
- className="student-history-menu-button"
194
- disabled
195
- >
196
- <Clock3 className="student-history-menu-icon size-4" />
197
- <span className="student-history-copy">
198
- <span className="student-history-title">
199
- Memuat riwayat
200
- </span>
201
- </span>
202
- </SidebarMenuButton>
203
- </SidebarMenuItem>
204
- )}
205
-
206
- {chatSessions.map((session) => {
207
- const sessionTime =
208
- formatSessionTime(
209
- session.last_message_at ??
210
- session.updated_at ??
211
- session.created_at,
212
- );
213
 
214
- return (
215
- <SidebarMenuItem
216
- className="student-history-menu-item"
217
- key={session.uuid_id}
218
- >
219
  <SidebarMenuButton
220
  className="student-history-menu-button"
221
- isActive={
222
- session.uuid_id ===
223
- activeSessionId
224
- }
225
- onClick={() =>
226
- router.visit(
227
- showMahasiswaSession(
228
- session.uuid_id,
229
- ),
230
- )
231
- }
232
- tooltip={session.title}
233
- type="button"
234
  >
235
  <Clock3 className="student-history-menu-icon size-4" />
236
  <span className="student-history-copy">
237
  <span className="student-history-title">
238
- {session.title}
239
  </span>
240
- {sessionTime && (
241
- <span className="student-history-meta">
242
- {
243
- sessionTime
244
- }
245
- </span>
246
- )}
247
  </span>
248
  </SidebarMenuButton>
 
 
 
 
 
 
 
 
 
 
249
 
250
- {onDeleteSession && (
251
- <button
252
- aria-label={`Hapus sesi ${session.title}`}
253
- className="student-history-delete"
254
- onClick={(
255
- event,
256
- ) => {
257
- event.stopPropagation();
258
- onDeleteSession(
259
- session.uuid_id,
260
- );
261
- }}
 
 
 
 
 
 
 
 
 
262
  type="button"
263
  >
264
- <Trash2 className="size-3" />
265
- </button>
266
- )}
267
- </SidebarMenuItem>
268
- );
269
- })}
270
- </SidebarMenu>
271
- </SidebarGroupContent>
272
- )}
273
- </>
274
- )}
275
- </SidebarGroup>
276
- </SidebarContent>
277
-
278
- <SidebarFooter className="student-sidebar-footer">
279
- <SidebarMenu>
280
- <SidebarMenuItem>
281
- <SidebarMenuButton
282
- className="student-user-menu-button"
283
- tooltip={studentName}
284
- >
285
- <Avatar className="student-user-avatar">
286
- <AvatarFallback>
287
- {getInitials(studentName) || 'AK'}
288
- </AvatarFallback>
289
- </Avatar>
290
- <span className="student-user-copy">
291
- <span className="student-user-name">
292
- {studentName}
293
- </span>
294
- <span className="student-user-role">
295
- Mahasiswa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
296
  </span>
297
- </span>
298
- </SidebarMenuButton>
299
- </SidebarMenuItem>
300
-
301
- <SidebarMenuItem>
302
- <SidebarMenuButton
303
- className="student-logout-button"
304
- onClick={handleLogout}
305
- tooltip="Logout"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
306
  >
307
- <LogOut className="size-4" />
308
- <span>Logout</span>
309
- </SidebarMenuButton>
310
- </SidebarMenuItem>
311
- </SidebarMenu>
312
- </SidebarFooter>
313
-
314
- <SidebarRail />
315
- </Sidebar>
316
  );
317
  }
318
 
@@ -328,7 +442,9 @@ export function StudentShell({
328
  chatSessions: ChatSessionResponse[];
329
  children: ReactNode;
330
  isLoadingSessions: boolean;
331
- onDeleteSession?: (sessionId: string) => void;
 
 
332
  studentName: string;
333
  }) {
334
  return (
 
12
  import AppearanceToggle from '@/components/appearance-toggle';
13
  import { Avatar, AvatarFallback } from '@/components/ui/avatar';
14
  import { Button } from '@/components/ui/button';
15
+ import {
16
+ Dialog,
17
+ DialogContent,
18
+ DialogDescription,
19
+ DialogFooter,
20
+ DialogHeader,
21
+ DialogTitle,
22
+ } from '@/components/ui/dialog';
23
  import {
24
  Sidebar,
25
  SidebarContent,
 
47
  '--sidebar-width-icon': '64px',
48
  } as CSSProperties;
49
 
50
+ export type DeleteSessionResult = {
51
+ ok: boolean;
52
+ message?: string;
53
+ };
54
+
55
  export function getStoredStudentName(): string {
56
  return getStoredAuthUser()?.name ?? 'Mahasiswa';
57
  }
 
144
  activeSessionId?: string;
145
  chatSessions: ChatSessionResponse[];
146
  isLoading: boolean;
147
+ onDeleteSession?: (
148
+ sessionId: string,
149
+ ) => DeleteSessionResult | Promise<DeleteSessionResult>;
150
  studentName: string;
151
  }) {
152
  const [isHistoryOpen, setIsHistoryOpen] = useState(true);
153
+ const [deleteDialogMessage, setDeleteDialogMessage] = useState<string>();
154
+ const [isDeletingSession, setIsDeletingSession] = useState(false);
155
+ const [sessionToDelete, setSessionToDelete] =
156
+ useState<ChatSessionResponse>();
157
  const { state } = useSidebar();
158
  const isSidebarCollapsed = state === 'collapsed';
159
 
160
+ function handleDeleteDialogOpenChange(open: boolean): void {
161
+ if (isDeletingSession) {
162
+ return;
163
+ }
164
+
165
+ if (!open) {
166
+ setSessionToDelete(undefined);
167
+ setDeleteDialogMessage(undefined);
168
+ }
169
+ }
170
+
171
+ async function handleConfirmDeleteSession(): Promise<void> {
172
+ if (!sessionToDelete || !onDeleteSession) {
173
+ return;
174
+ }
175
+
176
+ setIsDeletingSession(true);
177
+ setDeleteDialogMessage(undefined);
178
+
179
+ try {
180
+ const result = await onDeleteSession(sessionToDelete.uuid_id);
181
+
182
+ if (result.ok) {
183
+ setSessionToDelete(undefined);
184
+
185
+ return;
186
+ }
187
+
188
+ setDeleteDialogMessage(result.message);
189
+ } finally {
190
+ setIsDeletingSession(false);
191
+ }
192
+ }
193
+
194
  return (
195
+ <>
196
+ <Sidebar className="student-sidebar" collapsible="icon">
197
+ <SidebarHeader className="student-sidebar-top">
198
+ <StudentSidebarLogo />
199
+ <SidebarTrigger className="student-sidebar-trigger" />
200
+ </SidebarHeader>
201
+
202
+ <SidebarContent className="student-sidebar-content">
203
+ <SidebarGroup className="student-history-group">
204
+ {isSidebarCollapsed ? (
205
+ <SidebarGroupContent>
206
+ <SidebarMenu className="student-history-list">
207
+ <SidebarMenuItem>
208
+ <SidebarMenuButton
209
+ className="student-history-menu-button"
210
+ tooltip="Riwayat Chat"
211
+ >
212
+ <Clock3 className="student-history-menu-icon size-4" />
213
+ <span className="student-history-copy">
214
+ <span className="student-history-title">
215
+ Riwayat Chat
216
+ </span>
217
  </span>
218
+ </SidebarMenuButton>
219
+ </SidebarMenuItem>
220
+ </SidebarMenu>
221
+ </SidebarGroupContent>
222
+ ) : (
223
+ <>
224
+ <SidebarGroupLabel asChild>
225
+ <button
226
+ aria-expanded={isHistoryOpen}
227
+ className="student-history-label"
228
+ onClick={() =>
229
+ setIsHistoryOpen(
230
+ (currentState) => !currentState,
231
+ )
232
+ }
233
+ type="button"
234
+ >
235
+ <Clock3 className="size-4" />
236
+ <span>Riwayat Chat</span>
237
+ <ChevronDown className="student-history-chevron" />
238
+ </button>
239
+ </SidebarGroupLabel>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
240
 
241
+ {isHistoryOpen && (
242
+ <SidebarGroupContent>
243
+ <SidebarMenu className="student-history-list">
244
+ {isLoading && (
245
+ <SidebarMenuItem>
246
  <SidebarMenuButton
247
  className="student-history-menu-button"
248
+ disabled
 
 
 
 
 
 
 
 
 
 
 
 
249
  >
250
  <Clock3 className="student-history-menu-icon size-4" />
251
  <span className="student-history-copy">
252
  <span className="student-history-title">
253
+ Memuat riwayat
254
  </span>
 
 
 
 
 
 
 
255
  </span>
256
  </SidebarMenuButton>
257
+ </SidebarMenuItem>
258
+ )}
259
+
260
+ {chatSessions.map((session) => {
261
+ const sessionTime =
262
+ formatSessionTime(
263
+ session.last_message_at ??
264
+ session.updated_at ??
265
+ session.created_at,
266
+ );
267
 
268
+ return (
269
+ <SidebarMenuItem
270
+ className="student-history-menu-item"
271
+ key={session.uuid_id}
272
+ >
273
+ <SidebarMenuButton
274
+ className="student-history-menu-button"
275
+ isActive={
276
+ session.uuid_id ===
277
+ activeSessionId
278
+ }
279
+ onClick={() =>
280
+ router.visit(
281
+ showMahasiswaSession(
282
+ session.uuid_id,
283
+ ),
284
+ )
285
+ }
286
+ tooltip={
287
+ session.title
288
+ }
289
  type="button"
290
  >
291
+ <Clock3 className="student-history-menu-icon size-4" />
292
+ <span className="student-history-copy">
293
+ <span className="student-history-title">
294
+ {
295
+ session.title
296
+ }
297
+ </span>
298
+ {sessionTime && (
299
+ <span className="student-history-meta">
300
+ {
301
+ sessionTime
302
+ }
303
+ </span>
304
+ )}
305
+ </span>
306
+ </SidebarMenuButton>
307
+
308
+ {onDeleteSession && (
309
+ <button
310
+ aria-label={`Hapus sesi ${session.title}`}
311
+ className="student-history-delete"
312
+ onClick={(
313
+ event,
314
+ ) => {
315
+ event.stopPropagation();
316
+ setSessionToDelete(
317
+ session,
318
+ );
319
+ setDeleteDialogMessage(
320
+ undefined,
321
+ );
322
+ }}
323
+ type="button"
324
+ >
325
+ <Trash2 className="size-3" />
326
+ </button>
327
+ )}
328
+ </SidebarMenuItem>
329
+ );
330
+ })}
331
+ </SidebarMenu>
332
+ </SidebarGroupContent>
333
+ )}
334
+ </>
335
+ )}
336
+ </SidebarGroup>
337
+ </SidebarContent>
338
+
339
+ <SidebarFooter className="student-sidebar-footer">
340
+ <SidebarMenu>
341
+ <SidebarMenuItem>
342
+ <SidebarMenuButton
343
+ className="student-user-menu-button"
344
+ tooltip={studentName}
345
+ >
346
+ <Avatar className="student-user-avatar">
347
+ <AvatarFallback>
348
+ {getInitials(studentName) || 'AK'}
349
+ </AvatarFallback>
350
+ </Avatar>
351
+ <span className="student-user-copy">
352
+ <span className="student-user-name">
353
+ {studentName}
354
+ </span>
355
+ <span className="student-user-role">
356
+ Mahasiswa
357
+ </span>
358
  </span>
359
+ </SidebarMenuButton>
360
+ </SidebarMenuItem>
361
+
362
+ <SidebarMenuItem>
363
+ <SidebarMenuButton
364
+ className="student-logout-button"
365
+ onClick={handleLogout}
366
+ tooltip="Logout"
367
+ >
368
+ <LogOut className="size-4" />
369
+ <span>Logout</span>
370
+ </SidebarMenuButton>
371
+ </SidebarMenuItem>
372
+ </SidebarMenu>
373
+ </SidebarFooter>
374
+
375
+ <SidebarRail />
376
+ </Sidebar>
377
+
378
+ <Dialog
379
+ onOpenChange={handleDeleteDialogOpenChange}
380
+ open={Boolean(sessionToDelete)}
381
+ >
382
+ <DialogContent className="student-delete-dialog">
383
+ <DialogHeader>
384
+ <DialogTitle>Hapus Riwayat Chat</DialogTitle>
385
+ <DialogDescription>
386
+ Sesi chat ini akan dihapus dari riwayat.
387
+ </DialogDescription>
388
+ </DialogHeader>
389
+
390
+ <div className="student-delete-dialog-body">
391
+ <p className="student-delete-copy">
392
+ Hapus sesi{' '}
393
+ <strong>
394
+ {sessionToDelete?.title ?? 'chat ini'}
395
+ </strong>
396
+ ?
397
+ </p>
398
+
399
+ {deleteDialogMessage && (
400
+ <p className="student-delete-error" role="alert">
401
+ {deleteDialogMessage}
402
+ </p>
403
+ )}
404
+ </div>
405
+
406
+ <DialogFooter className="student-delete-dialog-footer">
407
+ <Button
408
+ className="student-delete-cancel"
409
+ disabled={isDeletingSession}
410
+ onClick={() => handleDeleteDialogOpenChange(false)}
411
+ type="button"
412
+ variant="outline"
413
+ >
414
+ Batal
415
+ </Button>
416
+ <Button
417
+ className="student-delete-confirm"
418
+ disabled={isDeletingSession}
419
+ onClick={() => {
420
+ void handleConfirmDeleteSession();
421
+ }}
422
+ type="button"
423
  >
424
+ {isDeletingSession ? 'Menghapus...' : 'Hapus'}
425
+ </Button>
426
+ </DialogFooter>
427
+ </DialogContent>
428
+ </Dialog>
429
+ </>
 
 
 
430
  );
431
  }
432
 
 
442
  chatSessions: ChatSessionResponse[];
443
  children: ReactNode;
444
  isLoadingSessions: boolean;
445
+ onDeleteSession?: (
446
+ sessionId: string,
447
+ ) => DeleteSessionResult | Promise<DeleteSessionResult>;
448
  studentName: string;
449
  }) {
450
  return (
resources/js/lib/rag.ts CHANGED
@@ -24,6 +24,46 @@ export type CourseListResponse = {
24
  limit: number;
25
  };
26
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  export type ChatSessionResponse = {
28
  uuid_id: string;
29
  user_id: string;
@@ -74,6 +114,12 @@ export type ChatMessageResponse = {
74
  created_at: string;
75
  };
76
 
 
 
 
 
 
 
77
  export type ChatQueryResponse = {
78
  message_id: string;
79
  role?: string;
@@ -106,11 +152,17 @@ type SendConfiguredChatMessagePayload = {
106
  };
107
 
108
  const DIRECT_CHAT_STORAGE_PREFIX = 'sevima_raghub_direct_messages';
 
 
109
 
110
  function getDirectChatStorageKey(sessionId: string): string {
111
  return `${DIRECT_CHAT_STORAGE_PREFIX}:${sessionId}`;
112
  }
113
 
 
 
 
 
114
  function getBrowserSessionStorage(): Storage | null {
115
  if (typeof window === 'undefined') {
116
  return null;
@@ -197,6 +249,50 @@ export function clearDirectChatMessages(sessionId: string): void {
197
  getBrowserSessionStorage()?.removeItem(getDirectChatStorageKey(sessionId));
198
  }
199
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
200
  export const ragApi = {
201
  listCourses({ page = 1, limit = 100, signal }: ListCoursesOptions = {}) {
202
  return api.get<CourseListResponse>('/courses', {
@@ -205,6 +301,38 @@ export const ragApi = {
205
  });
206
  },
207
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
208
  listChatSessions({
209
  page = 1,
210
  limit = 20,
@@ -225,6 +353,26 @@ export const ragApi = {
225
  });
226
  },
227
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
228
  getChatSession(
229
  sessionId: string,
230
  { signal, token }: AuthenticatedRequestOptions = {},
 
24
  limit: number;
25
  };
26
 
27
+ export type CreateCourseRequest = {
28
+ title: string;
29
+ description?: string;
30
+ };
31
+
32
+ export type UpdateCourseRequest = {
33
+ title?: string;
34
+ description?: string;
35
+ };
36
+
37
+ export type DocumentStatus = 'processing' | 'ready' | 'failed';
38
+
39
+ export type DocumentResponse = {
40
+ id: string;
41
+ filename: string;
42
+ file_type: string;
43
+ file_size: number;
44
+ status: DocumentStatus;
45
+ course_id: string;
46
+ course_name?: string;
47
+ uploaded_by: string;
48
+ uploader_name?: string;
49
+ summary?: string;
50
+ chunk_count?: number;
51
+ error?: string;
52
+ created_at: string;
53
+ updated_at: string;
54
+ };
55
+
56
+ export type DocumentListResponse = {
57
+ data: DocumentResponse[];
58
+ };
59
+
60
+ export type UploadDocumentResponse = {
61
+ document_id: string;
62
+ filename: string;
63
+ status: DocumentStatus;
64
+ message: string;
65
+ };
66
+
67
  export type ChatSessionResponse = {
68
  uuid_id: string;
69
  user_id: string;
 
114
  created_at: string;
115
  };
116
 
117
+ export type PendingInitialChat = {
118
+ content: string;
119
+ courseId: string;
120
+ userMessage: ChatMessageResponse;
121
+ };
122
+
123
  export type ChatQueryResponse = {
124
  message_id: string;
125
  role?: string;
 
152
  };
153
 
154
  const DIRECT_CHAT_STORAGE_PREFIX = 'sevima_raghub_direct_messages';
155
+ const PENDING_INITIAL_CHAT_STORAGE_PREFIX =
156
+ 'sevima_raghub_pending_initial_chat';
157
 
158
  function getDirectChatStorageKey(sessionId: string): string {
159
  return `${DIRECT_CHAT_STORAGE_PREFIX}:${sessionId}`;
160
  }
161
 
162
+ function getPendingInitialChatStorageKey(sessionId: string): string {
163
+ return `${PENDING_INITIAL_CHAT_STORAGE_PREFIX}:${sessionId}`;
164
+ }
165
+
166
  function getBrowserSessionStorage(): Storage | null {
167
  if (typeof window === 'undefined') {
168
  return null;
 
249
  getBrowserSessionStorage()?.removeItem(getDirectChatStorageKey(sessionId));
250
  }
251
 
252
+ export function getPendingInitialChat(
253
+ sessionId: string,
254
+ ): PendingInitialChat | undefined {
255
+ const rawPendingChat = getBrowserSessionStorage()?.getItem(
256
+ getPendingInitialChatStorageKey(sessionId),
257
+ );
258
+
259
+ if (!rawPendingChat) {
260
+ return undefined;
261
+ }
262
+
263
+ try {
264
+ const pendingChat = JSON.parse(rawPendingChat) as PendingInitialChat;
265
+
266
+ if (
267
+ typeof pendingChat.content === 'string' &&
268
+ typeof pendingChat.courseId === 'string' &&
269
+ typeof pendingChat.userMessage?.uuid_id === 'string'
270
+ ) {
271
+ return pendingChat;
272
+ }
273
+ } catch {
274
+ return undefined;
275
+ }
276
+
277
+ return undefined;
278
+ }
279
+
280
+ export function savePendingInitialChat(
281
+ sessionId: string,
282
+ pendingChat: PendingInitialChat,
283
+ ): void {
284
+ getBrowserSessionStorage()?.setItem(
285
+ getPendingInitialChatStorageKey(sessionId),
286
+ JSON.stringify(pendingChat),
287
+ );
288
+ }
289
+
290
+ export function clearPendingInitialChat(sessionId: string): void {
291
+ getBrowserSessionStorage()?.removeItem(
292
+ getPendingInitialChatStorageKey(sessionId),
293
+ );
294
+ }
295
+
296
  export const ragApi = {
297
  listCourses({ page = 1, limit = 100, signal }: ListCoursesOptions = {}) {
298
  return api.get<CourseListResponse>('/courses', {
 
301
  });
302
  },
303
 
304
+ getCourse(
305
+ courseId: string,
306
+ { signal, token }: AuthenticatedRequestOptions = {},
307
+ ) {
308
+ return api.get<CourseResponse>(`/courses/${courseId}`, {
309
+ signal,
310
+ token,
311
+ });
312
+ },
313
+
314
+ createCourse(payload: CreateCourseRequest, token?: string) {
315
+ return api.post<CourseResponse>('/courses', payload, {
316
+ token,
317
+ });
318
+ },
319
+
320
+ updateCourse(
321
+ courseId: string,
322
+ payload: UpdateCourseRequest,
323
+ token?: string,
324
+ ) {
325
+ return api.put<CourseResponse>(`/courses/${courseId}`, payload, {
326
+ token,
327
+ });
328
+ },
329
+
330
+ deleteCourse(courseId: string, token?: string) {
331
+ return api.delete<unknown>(`/courses/${courseId}`, {
332
+ token,
333
+ });
334
+ },
335
+
336
  listChatSessions({
337
  page = 1,
338
  limit = 20,
 
353
  });
354
  },
355
 
356
+ listCourseDocuments(
357
+ courseId: string,
358
+ { signal, token }: AuthenticatedRequestOptions = {},
359
+ ) {
360
+ return api.get<DocumentListResponse>(`/courses/${courseId}/documents`, {
361
+ signal,
362
+ token,
363
+ });
364
+ },
365
+
366
+ uploadDocument(courseId: string, file: File, token?: string) {
367
+ const formData = new FormData();
368
+ formData.append('file', file);
369
+ formData.append('course_id', courseId);
370
+
371
+ return api.post<UploadDocumentResponse>('/documents/upload', formData, {
372
+ token,
373
+ });
374
+ },
375
+
376
  getChatSession(
377
  sessionId: string,
378
  { signal, token }: AuthenticatedRequestOptions = {},
resources/js/pages/auth/login.tsx CHANGED
@@ -8,7 +8,7 @@ import { Checkbox } from '@/components/ui/checkbox';
8
  import { Input } from '@/components/ui/input';
9
  import { Label } from '@/components/ui/label';
10
  import { authApi, getAuthErrorMessage, saveAuthSession } from '@/lib/auth';
11
- import { dashboard, home, mahasiswa, register } from '@/routes';
12
 
13
  type LoginProps = {
14
  canRegister?: boolean;
@@ -37,9 +37,19 @@ export default function Login({ canRegister = true, status }: LoginProps) {
37
 
38
  saveAuthSession(response, remember);
39
 
40
- router.visit(
41
- response.user.role === 'student' ? mahasiswa() : dashboard(),
42
- );
 
 
 
 
 
 
 
 
 
 
43
  } catch (error) {
44
  setBackendError(getAuthErrorMessage(error));
45
  } finally {
 
8
  import { Input } from '@/components/ui/input';
9
  import { Label } from '@/components/ui/label';
10
  import { authApi, getAuthErrorMessage, saveAuthSession } from '@/lib/auth';
11
+ import { dashboard, dosen, home, mahasiswa, register } from '@/routes';
12
 
13
  type LoginProps = {
14
  canRegister?: boolean;
 
37
 
38
  saveAuthSession(response, remember);
39
 
40
+ if (response.user.role === 'student') {
41
+ router.visit(mahasiswa());
42
+
43
+ return;
44
+ }
45
+
46
+ if (response.user.role === 'lecturer') {
47
+ router.visit(dosen());
48
+
49
+ return;
50
+ }
51
+
52
+ router.visit(dashboard());
53
  } catch (error) {
54
  setBackendError(getAuthErrorMessage(error));
55
  } finally {
resources/js/pages/dosen-detail.tsx ADDED
@@ -0,0 +1,618 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Head, router } from '@inertiajs/react';
2
+ import {
3
+ AlertCircle,
4
+ ArrowLeft,
5
+ CheckCircle2,
6
+ FileText,
7
+ LoaderCircle,
8
+ Pencil,
9
+ Trash2,
10
+ Upload,
11
+ } from 'lucide-react';
12
+ import type { ChangeEvent, DragEvent, FormEvent } from 'react';
13
+ import { useCallback, useEffect, useRef, useState } from 'react';
14
+
15
+ import { DosenShell } from '@/components/dosen/dosen-shell';
16
+ import { Button } from '@/components/ui/button';
17
+ import { Card, CardContent } from '@/components/ui/card';
18
+ import {
19
+ Dialog,
20
+ DialogContent,
21
+ DialogDescription,
22
+ DialogFooter,
23
+ DialogHeader,
24
+ DialogTitle,
25
+ } from '@/components/ui/dialog';
26
+ import { Input } from '@/components/ui/input';
27
+ import { Label } from '@/components/ui/label';
28
+ import { getStoredAuthToken } from '@/lib/auth';
29
+ import type {
30
+ CourseResponse,
31
+ DocumentResponse,
32
+ DocumentStatus,
33
+ } from '@/lib/rag';
34
+ import { getRagErrorMessage, ragApi } from '@/lib/rag';
35
+ import { dosen } from '@/routes';
36
+
37
+ type DosenDetailProps = {
38
+ courseId: string;
39
+ };
40
+
41
+ type CourseForm = {
42
+ title: string;
43
+ description: string;
44
+ };
45
+
46
+ const emptyCourseForm: CourseForm = {
47
+ description: '',
48
+ title: '',
49
+ };
50
+
51
+ const acceptedDocumentTypes = [
52
+ '.pdf',
53
+ '.ppt',
54
+ '.pptx',
55
+ '.txt',
56
+ 'application/pdf',
57
+ 'application/vnd.ms-powerpoint',
58
+ 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
59
+ 'text/plain',
60
+ ].join(',');
61
+
62
+ function formatDate(value?: string): string {
63
+ if (!value) {
64
+ return '-';
65
+ }
66
+
67
+ const datePart = value.split('T')[0];
68
+
69
+ return datePart || value;
70
+ }
71
+
72
+ function formatFileSize(bytes: number): string {
73
+ if (!Number.isFinite(bytes) || bytes <= 0) {
74
+ return '-';
75
+ }
76
+
77
+ const units = ['B', 'KB', 'MB', 'GB'];
78
+ let size = bytes;
79
+ let unitIndex = 0;
80
+
81
+ while (size >= 1024 && unitIndex < units.length - 1) {
82
+ size /= 1024;
83
+ unitIndex += 1;
84
+ }
85
+
86
+ return `${size.toFixed(unitIndex === 0 ? 0 : 1)} ${units[unitIndex]}`;
87
+ }
88
+
89
+ function getStatusLabel(status: DocumentStatus): string {
90
+ if (status === 'failed') {
91
+ return 'Gagal diproses';
92
+ }
93
+
94
+ if (status === 'processing') {
95
+ return 'Sedang diproses';
96
+ }
97
+
98
+ return 'Tersedia untuk Q&A';
99
+ }
100
+
101
+ function DocumentStatusIcon({ status }: { status: DocumentStatus }) {
102
+ if (status === 'failed') {
103
+ return <AlertCircle className="size-4" />;
104
+ }
105
+
106
+ if (status === 'processing') {
107
+ return <LoaderCircle className="size-4 animate-spin" />;
108
+ }
109
+
110
+ return <CheckCircle2 className="size-4" />;
111
+ }
112
+
113
+ function DocumentCard({ document }: { document: DocumentResponse }) {
114
+ const statusTone = document.status;
115
+
116
+ return (
117
+ <Card className="lecturer-document-card" data-status={statusTone}>
118
+ <CardContent className="lecturer-document-content">
119
+ <span
120
+ className="lecturer-document-icon"
121
+ data-status={statusTone}
122
+ >
123
+ <FileText className="size-5" />
124
+ </span>
125
+
126
+ <div className="lecturer-document-copy">
127
+ <h3>{document.filename}</h3>
128
+ <p className="lecturer-document-meta">
129
+ <span>{formatFileSize(document.file_size)}</span>
130
+ <span aria-hidden="true">&middot;</span>
131
+ <span>{formatDate(document.created_at)}</span>
132
+ {document.chunk_count !== undefined && (
133
+ <>
134
+ <span aria-hidden="true">&middot;</span>
135
+ <span>{document.chunk_count} chunks</span>
136
+ </>
137
+ )}
138
+ </p>
139
+
140
+ <p
141
+ className="lecturer-document-status"
142
+ data-status={statusTone}
143
+ >
144
+ <DocumentStatusIcon status={document.status} />
145
+ {getStatusLabel(document.status)}
146
+ </p>
147
+ </div>
148
+
149
+ <div className="lecturer-document-actions">
150
+ <button
151
+ aria-label={`Hapus ${document.filename}`}
152
+ className="lecturer-document-delete"
153
+ type="button"
154
+ >
155
+ <Trash2 className="size-4" />
156
+ </button>
157
+ </div>
158
+ </CardContent>
159
+ </Card>
160
+ );
161
+ }
162
+
163
+ export default function DosenDetail({ courseId }: DosenDetailProps) {
164
+ const [course, setCourse] = useState<CourseResponse>();
165
+ const [documents, setDocuments] = useState<DocumentResponse[]>([]);
166
+ const [deleteCourseMessage, setDeleteCourseMessage] = useState<string>();
167
+ const [editCourseMessage, setEditCourseMessage] = useState<string>();
168
+ const [editForm, setEditForm] = useState<CourseForm>(emptyCourseForm);
169
+ const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
170
+ const [isDeletingCourse, setIsDeletingCourse] = useState(false);
171
+ const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
172
+ const [isLoading, setIsLoading] = useState(true);
173
+ const [isUpdatingCourse, setIsUpdatingCourse] = useState(false);
174
+ const [isUploading, setIsUploading] = useState(false);
175
+ const fileInputRef = useRef<HTMLInputElement>(null);
176
+
177
+ const loadCourseDetail = useCallback(
178
+ async (signal?: AbortSignal): Promise<void> => {
179
+ setIsLoading(true);
180
+
181
+ const token = getStoredAuthToken() ?? undefined;
182
+
183
+ const [courseResult, documentsResult] = await Promise.allSettled([
184
+ ragApi.getCourse(courseId, {
185
+ signal,
186
+ token,
187
+ }),
188
+ ragApi.listCourseDocuments(courseId, {
189
+ signal,
190
+ token,
191
+ }),
192
+ ]);
193
+
194
+ if (signal?.aborted) {
195
+ return;
196
+ }
197
+
198
+ if (courseResult.status === 'fulfilled') {
199
+ setCourse(courseResult.value);
200
+ }
201
+
202
+ if (documentsResult.status === 'fulfilled') {
203
+ setDocuments(documentsResult.value.data);
204
+ } else {
205
+ setDocuments([]);
206
+ }
207
+
208
+ setIsLoading(false);
209
+ },
210
+ [courseId],
211
+ );
212
+
213
+ useEffect(() => {
214
+ const controller = new AbortController();
215
+
216
+ void Promise.resolve().then(() => loadCourseDetail(controller.signal));
217
+
218
+ return () => {
219
+ controller.abort();
220
+ };
221
+ }, [loadCourseDetail]);
222
+
223
+ async function handleUploadDocument(file: File): Promise<void> {
224
+ setIsUploading(true);
225
+
226
+ try {
227
+ await ragApi.uploadDocument(
228
+ courseId,
229
+ file,
230
+ getStoredAuthToken() ?? undefined,
231
+ );
232
+
233
+ await loadCourseDetail();
234
+ } catch {
235
+ // Backend upload messages are intentionally hidden in this view.
236
+ } finally {
237
+ setIsUploading(false);
238
+ }
239
+ }
240
+
241
+ function handleEditDialogOpenChange(open: boolean): void {
242
+ if (isUpdatingCourse) {
243
+ return;
244
+ }
245
+
246
+ setIsEditDialogOpen(open);
247
+ setEditCourseMessage(undefined);
248
+
249
+ if (open) {
250
+ setEditForm({
251
+ description: course?.description ?? '',
252
+ title: course?.title ?? '',
253
+ });
254
+ }
255
+ }
256
+
257
+ async function handleUpdateCourseSubmit(
258
+ event: FormEvent<HTMLFormElement>,
259
+ ): Promise<void> {
260
+ event.preventDefault();
261
+ setEditCourseMessage(undefined);
262
+ setIsUpdatingCourse(true);
263
+
264
+ try {
265
+ const updatedCourse = await ragApi.updateCourse(
266
+ courseId,
267
+ {
268
+ description: editForm.description.trim(),
269
+ title: editForm.title.trim(),
270
+ },
271
+ getStoredAuthToken() ?? undefined,
272
+ );
273
+
274
+ setCourse(updatedCourse);
275
+ setIsEditDialogOpen(false);
276
+ } catch (error) {
277
+ setEditCourseMessage(getRagErrorMessage(error));
278
+ } finally {
279
+ setIsUpdatingCourse(false);
280
+ }
281
+ }
282
+
283
+ function handleDeleteDialogOpenChange(open: boolean): void {
284
+ if (isDeletingCourse) {
285
+ return;
286
+ }
287
+
288
+ setIsDeleteDialogOpen(open);
289
+ setDeleteCourseMessage(undefined);
290
+ }
291
+
292
+ async function handleDeleteCourse(): Promise<void> {
293
+ setDeleteCourseMessage(undefined);
294
+ setIsDeletingCourse(true);
295
+
296
+ try {
297
+ await ragApi.deleteCourse(
298
+ courseId,
299
+ getStoredAuthToken() ?? undefined,
300
+ );
301
+ router.visit(dosen());
302
+ } catch (error) {
303
+ setDeleteCourseMessage(getRagErrorMessage(error));
304
+ } finally {
305
+ setIsDeletingCourse(false);
306
+ }
307
+ }
308
+
309
+ function handleFileInputChange(event: ChangeEvent<HTMLInputElement>): void {
310
+ const file = event.target.files?.[0];
311
+ event.target.value = '';
312
+
313
+ if (file) {
314
+ void handleUploadDocument(file);
315
+ }
316
+ }
317
+
318
+ function handleUploadCardClick(): void {
319
+ if (!isUploading) {
320
+ fileInputRef.current?.click();
321
+ }
322
+ }
323
+
324
+ function handleUploadCardDrop(event: DragEvent<HTMLButtonElement>): void {
325
+ event.preventDefault();
326
+
327
+ if (isUploading) {
328
+ return;
329
+ }
330
+
331
+ const file = event.dataTransfer.files[0];
332
+
333
+ if (file) {
334
+ void handleUploadDocument(file);
335
+ }
336
+ }
337
+
338
+ const title =
339
+ course?.title ??
340
+ (isLoading ? 'Memuat mata kuliah...' : 'Detail mata kuliah');
341
+ const description =
342
+ course?.description ??
343
+ (isLoading
344
+ ? 'Mengambil detail mata kuliah dari backend.'
345
+ : 'Tidak ada deskripsi mata kuliah.');
346
+
347
+ return (
348
+ <>
349
+ <Head title={course ? course.title : 'Detail Mata Kuliah'} />
350
+
351
+ <DosenShell>
352
+ <main className="lecturer-detail-page">
353
+ <section className="lecturer-detail-header">
354
+ <button
355
+ aria-label="Kembali ke dashboard dosen"
356
+ className="lecturer-detail-back"
357
+ onClick={() => router.visit(dosen())}
358
+ type="button"
359
+ >
360
+ <ArrowLeft className="size-6" />
361
+ </button>
362
+
363
+ <div className="lecturer-detail-heading">
364
+ <div className="lecturer-detail-title-row">
365
+ <h1>{title}</h1>
366
+ </div>
367
+ <p>{description}</p>
368
+ </div>
369
+
370
+ <div className="lecturer-detail-actions">
371
+ <Button
372
+ className="lecturer-edit-button"
373
+ disabled={!course || isLoading}
374
+ onClick={() => handleEditDialogOpenChange(true)}
375
+ variant="outline"
376
+ >
377
+ <Pencil className="size-5" />
378
+ Edit
379
+ </Button>
380
+ <Button
381
+ className="lecturer-delete-button"
382
+ disabled={!course || isLoading}
383
+ onClick={() =>
384
+ handleDeleteDialogOpenChange(true)
385
+ }
386
+ variant="ghost"
387
+ >
388
+ <Trash2 className="size-5" />
389
+ </Button>
390
+ </div>
391
+ </section>
392
+
393
+ <section className="lecturer-detail-content">
394
+ <input
395
+ accept={acceptedDocumentTypes}
396
+ className="lecturer-upload-input"
397
+ disabled={isUploading}
398
+ onChange={handleFileInputChange}
399
+ ref={fileInputRef}
400
+ type="file"
401
+ />
402
+
403
+ <button
404
+ className="lecturer-upload-card"
405
+ data-uploading={isUploading}
406
+ disabled={isUploading}
407
+ onClick={handleUploadCardClick}
408
+ onDragOver={(event) => event.preventDefault()}
409
+ onDrop={handleUploadCardDrop}
410
+ type="button"
411
+ >
412
+ <span className="lecturer-upload-icon">
413
+ {isUploading ? (
414
+ <LoaderCircle className="size-7 animate-spin" />
415
+ ) : (
416
+ <Upload className="size-7" />
417
+ )}
418
+ </span>
419
+ <span className="lecturer-upload-title">
420
+ {isUploading
421
+ ? 'Mengupload dokumen...'
422
+ : 'Upload Dokumen Baru'}
423
+ </span>
424
+ <span className="lecturer-upload-description">
425
+ Drag & drop file PDF, PPT, atau TXT di sini.
426
+ Maks 100MB per file. Sistem otomatis mengekstrak
427
+ teks dan membuat embedding.
428
+ </span>
429
+ </button>
430
+
431
+ <section className="lecturer-document-section">
432
+ <h2 className="lecturer-document-heading">
433
+ <FileText className="size-5" />
434
+ <span>
435
+ Dokumen dalam Knowledge Base (
436
+ {documents.length})
437
+ </span>
438
+ </h2>
439
+
440
+ <div className="lecturer-document-list">
441
+ {isLoading && (
442
+ <p className="lecturer-detail-empty">
443
+ Memuat dokumen...
444
+ </p>
445
+ )}
446
+
447
+ {!isLoading && documents.length === 0 && (
448
+ <p className="lecturer-detail-empty">
449
+ Belum ada dokumen pada knowledge base
450
+ ini.
451
+ </p>
452
+ )}
453
+
454
+ {!isLoading &&
455
+ documents.map((document) => (
456
+ <DocumentCard
457
+ document={document}
458
+ key={document.id}
459
+ />
460
+ ))}
461
+ </div>
462
+ </section>
463
+ </section>
464
+ </main>
465
+
466
+ <Dialog
467
+ onOpenChange={handleEditDialogOpenChange}
468
+ open={isEditDialogOpen}
469
+ >
470
+ <DialogContent className="lecturer-course-dialog">
471
+ <DialogHeader>
472
+ <DialogTitle>Edit Mata Kuliah</DialogTitle>
473
+ <DialogDescription>
474
+ Perbarui nama dan deskripsi knowledge base.
475
+ </DialogDescription>
476
+ </DialogHeader>
477
+
478
+ <form
479
+ className="lecturer-course-form"
480
+ onSubmit={(event) => {
481
+ void handleUpdateCourseSubmit(event);
482
+ }}
483
+ >
484
+ <div className="lecturer-form-field">
485
+ <Label
486
+ className="lecturer-form-label"
487
+ htmlFor="edit-course-title"
488
+ >
489
+ Nama Mata Kuliah
490
+ </Label>
491
+ <Input
492
+ className="lecturer-form-input"
493
+ disabled={isUpdatingCourse}
494
+ id="edit-course-title"
495
+ onChange={(event) =>
496
+ setEditForm((current) => ({
497
+ ...current,
498
+ title: event.target.value,
499
+ }))
500
+ }
501
+ placeholder="Masukkan nama mata kuliah"
502
+ value={editForm.title}
503
+ />
504
+ </div>
505
+
506
+ <div className="lecturer-form-field">
507
+ <Label
508
+ className="lecturer-form-label"
509
+ htmlFor="edit-course-description"
510
+ >
511
+ Deskripsi
512
+ </Label>
513
+ <textarea
514
+ className="lecturer-form-textarea"
515
+ disabled={isUpdatingCourse}
516
+ id="edit-course-description"
517
+ onChange={(event) =>
518
+ setEditForm((current) => ({
519
+ ...current,
520
+ description: event.target.value,
521
+ }))
522
+ }
523
+ placeholder="Masukkan deskripsi mata kuliah"
524
+ value={editForm.description}
525
+ />
526
+ </div>
527
+
528
+ {editCourseMessage && (
529
+ <p className="lecturer-status" role="alert">
530
+ {editCourseMessage}
531
+ </p>
532
+ )}
533
+
534
+ <DialogFooter className="lecturer-course-footer">
535
+ <Button
536
+ className="lecturer-cancel-button"
537
+ disabled={isUpdatingCourse}
538
+ onClick={() =>
539
+ handleEditDialogOpenChange(false)
540
+ }
541
+ type="button"
542
+ variant="outline"
543
+ >
544
+ Batal
545
+ </Button>
546
+ <Button
547
+ className="lecturer-create-button"
548
+ disabled={isUpdatingCourse}
549
+ type="submit"
550
+ >
551
+ {isUpdatingCourse
552
+ ? 'Menyimpan...'
553
+ : 'Simpan'}
554
+ </Button>
555
+ </DialogFooter>
556
+ </form>
557
+ </DialogContent>
558
+ </Dialog>
559
+
560
+ <Dialog
561
+ onOpenChange={handleDeleteDialogOpenChange}
562
+ open={isDeleteDialogOpen}
563
+ >
564
+ <DialogContent className="lecturer-course-dialog">
565
+ <DialogHeader>
566
+ <DialogTitle>Hapus Mata Kuliah</DialogTitle>
567
+ <DialogDescription>
568
+ Tindakan ini akan menghapus knowledge base dari
569
+ backend.
570
+ </DialogDescription>
571
+ </DialogHeader>
572
+
573
+ <div className="lecturer-course-form">
574
+ <p className="lecturer-delete-copy">
575
+ Mata kuliah{' '}
576
+ <strong>{course?.title ?? title}</strong> akan
577
+ dihapus. Pastikan data ini memang sudah tidak
578
+ dipakai.
579
+ </p>
580
+
581
+ {deleteCourseMessage && (
582
+ <p className="lecturer-status" role="alert">
583
+ {deleteCourseMessage}
584
+ </p>
585
+ )}
586
+
587
+ <DialogFooter className="lecturer-course-footer">
588
+ <Button
589
+ className="lecturer-cancel-button"
590
+ disabled={isDeletingCourse}
591
+ onClick={() =>
592
+ handleDeleteDialogOpenChange(false)
593
+ }
594
+ type="button"
595
+ variant="outline"
596
+ >
597
+ Batal
598
+ </Button>
599
+ <Button
600
+ className="lecturer-danger-action-button"
601
+ disabled={isDeletingCourse}
602
+ onClick={() => {
603
+ void handleDeleteCourse();
604
+ }}
605
+ type="button"
606
+ >
607
+ {isDeletingCourse
608
+ ? 'Menghapus...'
609
+ : 'Hapus'}
610
+ </Button>
611
+ </DialogFooter>
612
+ </div>
613
+ </DialogContent>
614
+ </Dialog>
615
+ </DosenShell>
616
+ </>
617
+ );
618
+ }
resources/js/pages/dosen.tsx ADDED
@@ -0,0 +1,541 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Head, router } from '@inertiajs/react';
2
+ import type { LucideIcon } from 'lucide-react';
3
+ import { AlertTriangle, Database, FileText, Plus, Search } from 'lucide-react';
4
+ import type { FormEvent } from 'react';
5
+ import { useCallback, useEffect, useMemo, useState } from 'react';
6
+
7
+ import AppearanceToggle from '@/components/appearance-toggle';
8
+ import { DosenShell } from '@/components/dosen/dosen-shell';
9
+ import { Button } from '@/components/ui/button';
10
+ import { Card, CardContent } from '@/components/ui/card';
11
+ import {
12
+ Dialog,
13
+ DialogContent,
14
+ DialogDescription,
15
+ DialogFooter,
16
+ DialogHeader,
17
+ DialogTitle,
18
+ } from '@/components/ui/dialog';
19
+ import { Input } from '@/components/ui/input';
20
+ import { Label } from '@/components/ui/label';
21
+ import { getStoredAuthToken } from '@/lib/auth';
22
+ import type { CourseResponse, DocumentResponse } from '@/lib/rag';
23
+ import { getRagErrorMessage, ragApi } from '@/lib/rag';
24
+ import { show as showDosenCourse } from '@/routes/dosen';
25
+
26
+ type KnowledgeBaseRow = {
27
+ id: string;
28
+ name: string;
29
+ documentCount: number;
30
+ failedDocumentCount: number;
31
+ hasError?: boolean;
32
+ updatedAt: string;
33
+ };
34
+
35
+ type Metric = {
36
+ label: string;
37
+ value: string;
38
+ description?: string;
39
+ icon: LucideIcon;
40
+ tone: 'primary' | 'danger';
41
+ };
42
+
43
+ type CourseForm = {
44
+ title: string;
45
+ description: string;
46
+ };
47
+
48
+ const emptyCourseForm: CourseForm = {
49
+ description: '',
50
+ title: '',
51
+ };
52
+
53
+ function isAbortError(error: unknown): boolean {
54
+ return error instanceof DOMException && error.name === 'AbortError';
55
+ }
56
+
57
+ function formatUpdatedAt(value?: string): string {
58
+ if (!value) {
59
+ return '-';
60
+ }
61
+
62
+ const datePart = value.split('T')[0];
63
+
64
+ return datePart || value;
65
+ }
66
+
67
+ function getLatestUpdatedAt(documents: DocumentResponse[]): string | undefined {
68
+ return documents
69
+ .map((document) => document.updated_at)
70
+ .filter(Boolean)
71
+ .sort()
72
+ .at(-1);
73
+ }
74
+
75
+ function buildKnowledgeBaseRow(
76
+ course: CourseResponse,
77
+ documents: DocumentResponse[],
78
+ ): KnowledgeBaseRow {
79
+ const failedDocumentCount = documents.filter(
80
+ (document) => document.status === 'failed' || Boolean(document.error),
81
+ ).length;
82
+
83
+ return {
84
+ documentCount: documents.length,
85
+ failedDocumentCount,
86
+ hasError: failedDocumentCount > 0,
87
+ id: String(course.id),
88
+ name: course.title,
89
+ updatedAt: formatUpdatedAt(getLatestUpdatedAt(documents)),
90
+ };
91
+ }
92
+
93
+ function MetricCard({ metric }: { metric: Metric }) {
94
+ const Icon = metric.icon;
95
+
96
+ return (
97
+ <Card className="lecturer-metric-card">
98
+ <CardContent className="lecturer-metric-content">
99
+ <span className="lecturer-metric-icon" data-tone={metric.tone}>
100
+ <Icon className="size-6" />
101
+ </span>
102
+
103
+ <div className="lecturer-metric-copy">
104
+ <p>{metric.label}</p>
105
+ <div className="lecturer-metric-value-row">
106
+ <strong>{metric.value}</strong>
107
+ {metric.description && (
108
+ <span>{metric.description}</span>
109
+ )}
110
+ </div>
111
+ </div>
112
+ </CardContent>
113
+ </Card>
114
+ );
115
+ }
116
+
117
+ export default function Dosen() {
118
+ const [backendMessage, setBackendMessage] = useState<string>();
119
+ const [courseForm, setCourseForm] = useState<CourseForm>(emptyCourseForm);
120
+ const [createCourseMessage, setCreateCourseMessage] = useState<string>();
121
+ const [isCourseDialogOpen, setIsCourseDialogOpen] = useState(false);
122
+ const [isCreatingCourse, setIsCreatingCourse] = useState(false);
123
+ const [isLoading, setIsLoading] = useState(true);
124
+ const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBaseRow[]>(
125
+ [],
126
+ );
127
+ const [searchQuery, setSearchQuery] = useState('');
128
+
129
+ const loadKnowledgeBases = useCallback(async (signal?: AbortSignal) => {
130
+ setIsLoading(true);
131
+ setBackendMessage(undefined);
132
+
133
+ try {
134
+ const token = getStoredAuthToken() ?? undefined;
135
+ const coursesResponse = await ragApi.listCourses({
136
+ limit: 100,
137
+ signal,
138
+ });
139
+
140
+ if (signal?.aborted) {
141
+ return;
142
+ }
143
+
144
+ const documentResults = await Promise.allSettled(
145
+ coursesResponse.data.map((course) =>
146
+ ragApi.listCourseDocuments(String(course.id), {
147
+ signal,
148
+ token,
149
+ }),
150
+ ),
151
+ );
152
+
153
+ if (signal?.aborted) {
154
+ return;
155
+ }
156
+
157
+ let documentErrorMessage: string | undefined;
158
+
159
+ const rows = coursesResponse.data.map((course, index) => {
160
+ const documentResult = documentResults[index];
161
+
162
+ if (documentResult?.status === 'fulfilled') {
163
+ return buildKnowledgeBaseRow(
164
+ course,
165
+ documentResult.value.data,
166
+ );
167
+ }
168
+
169
+ if (documentResult && !isAbortError(documentResult.reason)) {
170
+ documentErrorMessage ??= getRagErrorMessage(
171
+ documentResult.reason,
172
+ );
173
+ }
174
+
175
+ return buildKnowledgeBaseRow(course, []);
176
+ });
177
+
178
+ setKnowledgeBases(rows);
179
+ setBackendMessage(documentErrorMessage);
180
+ } catch (error) {
181
+ if (!isAbortError(error)) {
182
+ setKnowledgeBases([]);
183
+ setBackendMessage(getRagErrorMessage(error));
184
+ }
185
+ } finally {
186
+ if (!signal?.aborted) {
187
+ setIsLoading(false);
188
+ }
189
+ }
190
+ }, []);
191
+
192
+ useEffect(() => {
193
+ const controller = new AbortController();
194
+
195
+ void Promise.resolve().then(() =>
196
+ loadKnowledgeBases(controller.signal),
197
+ );
198
+
199
+ return () => {
200
+ controller.abort();
201
+ };
202
+ }, [loadKnowledgeBases]);
203
+
204
+ function handleCourseDialogOpenChange(open: boolean): void {
205
+ setIsCourseDialogOpen(open);
206
+
207
+ if (!open) {
208
+ setCourseForm(emptyCourseForm);
209
+ setCreateCourseMessage(undefined);
210
+ }
211
+ }
212
+
213
+ async function handleCreateCourseSubmit(
214
+ event: FormEvent<HTMLFormElement>,
215
+ ): Promise<void> {
216
+ event.preventDefault();
217
+ setCreateCourseMessage(undefined);
218
+ setIsCreatingCourse(true);
219
+
220
+ try {
221
+ const token = getStoredAuthToken() ?? undefined;
222
+
223
+ await ragApi.createCourse(
224
+ {
225
+ description: courseForm.description || undefined,
226
+ title: courseForm.title,
227
+ },
228
+ token,
229
+ );
230
+
231
+ handleCourseDialogOpenChange(false);
232
+ await loadKnowledgeBases();
233
+ } catch (error) {
234
+ setCreateCourseMessage(getRagErrorMessage(error));
235
+ } finally {
236
+ setIsCreatingCourse(false);
237
+ }
238
+ }
239
+
240
+ const metrics = useMemo<Metric[]>(() => {
241
+ const failedDocumentCount = knowledgeBases.reduce(
242
+ (total, item) => total + item.failedDocumentCount,
243
+ 0,
244
+ );
245
+ const documentCount = knowledgeBases.reduce(
246
+ (total, item) => total + item.documentCount,
247
+ 0,
248
+ );
249
+
250
+ return [
251
+ {
252
+ icon: Database,
253
+ label: 'Total Knowledge Base',
254
+ tone: 'primary',
255
+ value: isLoading ? '...' : String(knowledgeBases.length),
256
+ },
257
+ {
258
+ icon: FileText,
259
+ label: 'Total Dokumen',
260
+ description: 'Chunked',
261
+ tone: 'primary',
262
+ value: isLoading ? '...' : String(documentCount),
263
+ },
264
+ {
265
+ icon: AlertTriangle,
266
+ label: 'Processing Error',
267
+ description: 'dokumen',
268
+ tone: 'danger',
269
+ value: isLoading ? '...' : String(failedDocumentCount),
270
+ },
271
+ ];
272
+ }, [isLoading, knowledgeBases]);
273
+
274
+ const filteredKnowledgeBases = useMemo(() => {
275
+ const normalizedQuery = searchQuery.trim().toLowerCase();
276
+
277
+ if (!normalizedQuery) {
278
+ return knowledgeBases;
279
+ }
280
+
281
+ return knowledgeBases.filter((item) =>
282
+ item.name.toLowerCase().includes(normalizedQuery),
283
+ );
284
+ }, [knowledgeBases, searchQuery]);
285
+
286
+ return (
287
+ <>
288
+ <Head title="Dashboard Knowledge Base" />
289
+
290
+ <DosenShell>
291
+ <main className="lecturer-page">
292
+ <section className="lecturer-page-header">
293
+ <div>
294
+ <h1>Dashboard Knowledge Base</h1>
295
+ <p>
296
+ Kelola sumber dokumen RAG untuk berbagai mata
297
+ kuliah Anda.
298
+ </p>
299
+ </div>
300
+
301
+ <div className="lecturer-header-actions">
302
+ <AppearanceToggle className="lecturer-theme-toggle" />
303
+ <Button
304
+ className="lecturer-create-button"
305
+ onClick={() =>
306
+ handleCourseDialogOpenChange(true)
307
+ }
308
+ >
309
+ <Plus className="size-4" />
310
+ Buat KB Baru
311
+ </Button>
312
+ </div>
313
+ </section>
314
+
315
+ <section className="lecturer-metric-grid">
316
+ {metrics.map((metric) => (
317
+ <MetricCard key={metric.label} metric={metric} />
318
+ ))}
319
+ </section>
320
+
321
+ <Card className="lecturer-table-card">
322
+ <CardContent className="lecturer-table-content">
323
+ <div className="lecturer-table-heading">
324
+ <h2>Daftar Mata Kuliah / Topik</h2>
325
+ <div className="lecturer-search-field">
326
+ <Search aria-hidden className="size-4" />
327
+ <Input
328
+ aria-label="Cari mata kuliah"
329
+ className="lecturer-search-input"
330
+ onChange={(event) =>
331
+ setSearchQuery(event.target.value)
332
+ }
333
+ placeholder="Cari mata kuliah..."
334
+ type="search"
335
+ value={searchQuery}
336
+ />
337
+ </div>
338
+ </div>
339
+
340
+ {backendMessage && (
341
+ <p className="lecturer-status" role="alert">
342
+ {backendMessage}
343
+ </p>
344
+ )}
345
+
346
+ <div className="lecturer-table-wrap">
347
+ <table className="lecturer-table">
348
+ <thead>
349
+ <tr>
350
+ <th>Nama Knowledge Base</th>
351
+ <th>Dokumen</th>
352
+ <th>Update Terakhir</th>
353
+ <th>Aksi</th>
354
+ </tr>
355
+ </thead>
356
+ <tbody>
357
+ {isLoading && (
358
+ <tr>
359
+ <td
360
+ className="lecturer-table-empty"
361
+ colSpan={4}
362
+ >
363
+ Memuat knowledge base...
364
+ </td>
365
+ </tr>
366
+ )}
367
+
368
+ {!isLoading &&
369
+ knowledgeBases.length === 0 && (
370
+ <tr>
371
+ <td
372
+ className="lecturer-table-empty"
373
+ colSpan={4}
374
+ >
375
+ Belum ada knowledge
376
+ base.
377
+ </td>
378
+ </tr>
379
+ )}
380
+
381
+ {!isLoading &&
382
+ knowledgeBases.length > 0 &&
383
+ filteredKnowledgeBases.length ===
384
+ 0 && (
385
+ <tr>
386
+ <td
387
+ className="lecturer-table-empty"
388
+ colSpan={4}
389
+ >
390
+ Mata kuliah tidak
391
+ ditemukan.
392
+ </td>
393
+ </tr>
394
+ )}
395
+
396
+ {!isLoading &&
397
+ filteredKnowledgeBases.map(
398
+ (item) => (
399
+ <tr key={item.id}>
400
+ <td>
401
+ <span className="lecturer-kb-name">
402
+ {item.name}
403
+ </span>
404
+ </td>
405
+ <td>
406
+ <span className="lecturer-doc-pill">
407
+ <FileText className="size-3.5" />
408
+ {
409
+ item.documentCount
410
+ }
411
+ {item.hasError && (
412
+ <span className="lecturer-doc-error" />
413
+ )}
414
+ </span>
415
+ </td>
416
+ <td>
417
+ {item.updatedAt}
418
+ </td>
419
+ <td>
420
+ <button
421
+ className="lecturer-manage-button"
422
+ onClick={() =>
423
+ router.visit(
424
+ showDosenCourse(
425
+ item.id,
426
+ ),
427
+ )
428
+ }
429
+ type="button"
430
+ >
431
+ Kelola
432
+ </button>
433
+ </td>
434
+ </tr>
435
+ ),
436
+ )}
437
+ </tbody>
438
+ </table>
439
+ </div>
440
+ </CardContent>
441
+ </Card>
442
+ </main>
443
+
444
+ <Dialog
445
+ onOpenChange={handleCourseDialogOpenChange}
446
+ open={isCourseDialogOpen}
447
+ >
448
+ <DialogContent className="lecturer-course-dialog">
449
+ <DialogHeader>
450
+ <DialogTitle>Tambah Mata Kuliah</DialogTitle>
451
+ <DialogDescription>
452
+ Buat knowledge base baru untuk dokumen RAG mata
453
+ kuliah.
454
+ </DialogDescription>
455
+ </DialogHeader>
456
+
457
+ <form
458
+ className="lecturer-course-form"
459
+ onSubmit={(event) => {
460
+ void handleCreateCourseSubmit(event);
461
+ }}
462
+ >
463
+ <div className="lecturer-form-field">
464
+ <Label
465
+ className="lecturer-form-label"
466
+ htmlFor="course-title"
467
+ >
468
+ Nama Mata Kuliah
469
+ </Label>
470
+ <Input
471
+ className="lecturer-form-input"
472
+ disabled={isCreatingCourse}
473
+ id="course-title"
474
+ onChange={(event) =>
475
+ setCourseForm((current) => ({
476
+ ...current,
477
+ title: event.target.value,
478
+ }))
479
+ }
480
+ placeholder="Masukkan nama mata kuliah"
481
+ value={courseForm.title}
482
+ />
483
+ </div>
484
+
485
+ <div className="lecturer-form-field">
486
+ <Label
487
+ className="lecturer-form-label"
488
+ htmlFor="course-description"
489
+ >
490
+ Deskripsi
491
+ </Label>
492
+ <textarea
493
+ className="lecturer-form-textarea"
494
+ disabled={isCreatingCourse}
495
+ id="course-description"
496
+ onChange={(event) =>
497
+ setCourseForm((current) => ({
498
+ ...current,
499
+ description: event.target.value,
500
+ }))
501
+ }
502
+ placeholder="Masukkan deskripsi mata kuliah"
503
+ value={courseForm.description}
504
+ />
505
+ </div>
506
+
507
+ {createCourseMessage && (
508
+ <p className="lecturer-status" role="alert">
509
+ {createCourseMessage}
510
+ </p>
511
+ )}
512
+
513
+ <DialogFooter className="lecturer-course-footer">
514
+ <Button
515
+ className="lecturer-cancel-button"
516
+ disabled={isCreatingCourse}
517
+ onClick={() =>
518
+ handleCourseDialogOpenChange(false)
519
+ }
520
+ type="button"
521
+ variant="outline"
522
+ >
523
+ Batal
524
+ </Button>
525
+ <Button
526
+ className="lecturer-create-button"
527
+ disabled={isCreatingCourse}
528
+ type="submit"
529
+ >
530
+ {isCreatingCourse
531
+ ? 'Menyimpan...'
532
+ : 'Simpan'}
533
+ </Button>
534
+ </DialogFooter>
535
+ </form>
536
+ </DialogContent>
537
+ </Dialog>
538
+ </DosenShell>
539
+ </>
540
+ );
541
+ }
resources/js/pages/dosen/pengaturan.tsx ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Head } from '@inertiajs/react';
2
+
3
+ import { DosenShell } from '@/components/dosen/dosen-shell';
4
+
5
+ export default function PengaturanDosen() {
6
+ return (
7
+ <>
8
+ <Head title="Pengaturan" />
9
+
10
+ <DosenShell activeMenu="settings">
11
+ <main className="lecturer-page">
12
+ <section className="lecturer-page-header">
13
+ <div>
14
+ <h1>Pengaturan</h1>
15
+ <p>Halaman ini masih kosong.</p>
16
+ </div>
17
+ </section>
18
+ </main>
19
+ </DosenShell>
20
+ </>
21
+ );
22
+ }
resources/js/pages/mahasiswa-chat.tsx CHANGED
@@ -4,6 +4,7 @@ import type { FormEvent } from 'react';
4
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
5
 
6
  import { QuestionComposer } from '@/components/student/question-composer';
 
7
  import {
8
  formatSessionTime,
9
  isAbortError,
@@ -20,7 +21,9 @@ import type {
20
  import {
21
  appendDirectChatMessages,
22
  clearDirectChatMessages,
 
23
  createLocalUserMessage,
 
24
  getRagErrorMessage,
25
  getStoredDirectChatMessages,
26
  isDirectChatMode,
@@ -95,16 +98,41 @@ function ChatMessage({ message }: { message: ChatMessageResponse }) {
95
  );
96
  }
97
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
98
  export default function MahasiswaChat({ sessionId }: MahasiswaChatProps) {
 
 
 
 
 
99
  const [backendMessage, setBackendMessage] = useState<string>();
100
  const [chatSessions, setChatSessions] = useState<ChatSessionResponse[]>([]);
101
  const [courses, setCourses] = useState<CourseResponse[]>([]);
102
  const [currentSession, setCurrentSession] = useState<ChatSessionResponse>();
103
  const [isLoading, setIsLoading] = useState(true);
104
- const [isProcessing, setIsProcessing] = useState(false);
105
- const [messages, setMessages] = useState<ChatMessageResponse[]>([]);
 
 
 
 
106
  const [question, setQuestion] = useState('');
107
- const [selectedCourseId, setSelectedCourseId] = useState('');
 
 
108
  const studentName = useStoredStudentName();
109
  const messagesEndRef = useRef<HTMLDivElement>(null);
110
 
@@ -130,22 +158,27 @@ export default function MahasiswaChat({ sessionId }: MahasiswaChatProps) {
130
  messagesEndRef.current?.scrollIntoView({ block: 'end' });
131
  }, [messages, isProcessing]);
132
 
133
- const applyHistoryResponse = useCallback(
134
- (response: ChatHistoryResponse): void => {
135
  if (isDirectChatMode()) {
136
- setMessages([
137
  ...response.data,
138
  ...getStoredDirectChatMessages(sessionId),
139
- ]);
140
-
141
- return;
142
  }
143
 
144
- setMessages(response.data);
145
  },
146
  [sessionId],
147
  );
148
 
 
 
 
 
 
 
 
149
  useEffect(() => {
150
  const controller = new AbortController();
151
 
@@ -196,7 +229,16 @@ export default function MahasiswaChat({ sessionId }: MahasiswaChatProps) {
196
  }
197
 
198
  if (historyResult.status === 'fulfilled') {
199
- applyHistoryResponse(historyResult.value);
 
 
 
 
 
 
 
 
 
200
  } else if (!isAbortError(historyResult.reason)) {
201
  setBackendMessage(getRagErrorMessage(historyResult.reason));
202
  }
@@ -209,9 +251,98 @@ export default function MahasiswaChat({ sessionId }: MahasiswaChatProps) {
209
  return () => {
210
  controller.abort();
211
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  }, [applyHistoryResponse, sessionId]);
213
 
214
- const handleDeleteSession = async (targetSessionId: string) => {
 
 
215
  try {
216
  const token = getStoredAuthToken() ?? undefined;
217
  await ragApi.deleteChatSession(targetSessionId, token);
@@ -226,8 +357,13 @@ export default function MahasiswaChat({ sessionId }: MahasiswaChatProps) {
226
  if (targetSessionId === sessionId) {
227
  router.visit(mahasiswa());
228
  }
 
 
229
  } catch (error) {
230
- setBackendMessage(getRagErrorMessage(error));
 
 
 
231
  }
232
  };
233
 
@@ -300,9 +436,7 @@ export default function MahasiswaChat({ sessionId }: MahasiswaChatProps) {
300
  activeSessionId={sessionId}
301
  chatSessions={chatSessions}
302
  isLoadingSessions={isLoading}
303
- onDeleteSession={(targetSessionId) => {
304
- void handleDeleteSession(targetSessionId);
305
- }}
306
  studentName={studentName}
307
  >
308
  <div className="student-chat-stage student-chat-stage--session">
@@ -313,7 +447,7 @@ export default function MahasiswaChat({ sessionId }: MahasiswaChatProps) {
313
  </p>
314
  )}
315
 
316
- {isLoading ? (
317
  <p className="student-chat-loading">
318
  Memuat percakapan...
319
  </p>
@@ -332,7 +466,7 @@ export default function MahasiswaChat({ sessionId }: MahasiswaChatProps) {
332
  <Bot className="size-4" />
333
  </span>
334
  <div className="student-message-bubble student-message-bubble--typing">
335
- Menyiapkan jawaban...
336
  </div>
337
  </article>
338
  )}
@@ -345,7 +479,6 @@ export default function MahasiswaChat({ sessionId }: MahasiswaChatProps) {
345
  <div className="student-chat-composer">
346
  <QuestionComposer
347
  courses={composerCourses}
348
- isCourseLocked
349
  isLoadingCourses={isLoading}
350
  isProcessing={isProcessing}
351
  onCourseChange={setSelectedCourseId}
 
4
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
5
 
6
  import { QuestionComposer } from '@/components/student/question-composer';
7
+ import type { DeleteSessionResult } from '@/components/student/student-shell';
8
  import {
9
  formatSessionTime,
10
  isAbortError,
 
21
  import {
22
  appendDirectChatMessages,
23
  clearDirectChatMessages,
24
+ clearPendingInitialChat,
25
  createLocalUserMessage,
26
+ getPendingInitialChat,
27
  getRagErrorMessage,
28
  getStoredDirectChatMessages,
29
  isDirectChatMode,
 
98
  );
99
  }
100
 
101
+ function appendUniqueMessages(
102
+ currentMessages: ChatMessageResponse[],
103
+ nextMessages: ChatMessageResponse[],
104
+ ): ChatMessageResponse[] {
105
+ const existingIds = new Set(
106
+ currentMessages.map((message) => message.uuid_id),
107
+ );
108
+
109
+ return [
110
+ ...currentMessages,
111
+ ...nextMessages.filter((message) => !existingIds.has(message.uuid_id)),
112
+ ];
113
+ }
114
+
115
  export default function MahasiswaChat({ sessionId }: MahasiswaChatProps) {
116
+ const [initialPendingChat] = useState(() =>
117
+ getPendingInitialChat(sessionId),
118
+ );
119
+ const pendingInitialChatRef = useRef(initialPendingChat);
120
+ const hasStartedPendingInitialChatRef = useRef(false);
121
  const [backendMessage, setBackendMessage] = useState<string>();
122
  const [chatSessions, setChatSessions] = useState<ChatSessionResponse[]>([]);
123
  const [courses, setCourses] = useState<CourseResponse[]>([]);
124
  const [currentSession, setCurrentSession] = useState<ChatSessionResponse>();
125
  const [isLoading, setIsLoading] = useState(true);
126
+ const [isProcessing, setIsProcessing] = useState(
127
+ Boolean(initialPendingChat),
128
+ );
129
+ const [messages, setMessages] = useState<ChatMessageResponse[]>(() =>
130
+ initialPendingChat ? [initialPendingChat.userMessage] : [],
131
+ );
132
  const [question, setQuestion] = useState('');
133
+ const [selectedCourseId, setSelectedCourseId] = useState(
134
+ initialPendingChat?.courseId ?? '',
135
+ );
136
  const studentName = useStoredStudentName();
137
  const messagesEndRef = useRef<HTMLDivElement>(null);
138
 
 
158
  messagesEndRef.current?.scrollIntoView({ block: 'end' });
159
  }, [messages, isProcessing]);
160
 
161
+ const getHistoryMessages = useCallback(
162
+ (response: ChatHistoryResponse): ChatMessageResponse[] => {
163
  if (isDirectChatMode()) {
164
+ return [
165
  ...response.data,
166
  ...getStoredDirectChatMessages(sessionId),
167
+ ];
 
 
168
  }
169
 
170
+ return response.data;
171
  },
172
  [sessionId],
173
  );
174
 
175
+ const applyHistoryResponse = useCallback(
176
+ (response: ChatHistoryResponse): void => {
177
+ setMessages(getHistoryMessages(response));
178
+ },
179
+ [getHistoryMessages],
180
+ );
181
+
182
  useEffect(() => {
183
  const controller = new AbortController();
184
 
 
229
  }
230
 
231
  if (historyResult.status === 'fulfilled') {
232
+ const historyMessages = getHistoryMessages(historyResult.value);
233
+ const pendingInitialChat = pendingInitialChatRef.current;
234
+
235
+ setMessages(
236
+ pendingInitialChat
237
+ ? appendUniqueMessages(historyMessages, [
238
+ pendingInitialChat.userMessage,
239
+ ])
240
+ : historyMessages,
241
+ );
242
  } else if (!isAbortError(historyResult.reason)) {
243
  setBackendMessage(getRagErrorMessage(historyResult.reason));
244
  }
 
251
  return () => {
252
  controller.abort();
253
  };
254
+ }, [getHistoryMessages, sessionId]);
255
+
256
+ useEffect(() => {
257
+ const pendingInitialChat = pendingInitialChatRef.current;
258
+
259
+ if (!pendingInitialChat || hasStartedPendingInitialChatRef.current) {
260
+ return;
261
+ }
262
+
263
+ const activePendingInitialChat = pendingInitialChat;
264
+ let isCancelled = false;
265
+ hasStartedPendingInitialChatRef.current = true;
266
+ setIsProcessing(true);
267
+ setQuestion('');
268
+ setSelectedCourseId(activePendingInitialChat.courseId);
269
+ setMessages((currentMessages) =>
270
+ appendUniqueMessages(currentMessages, [
271
+ activePendingInitialChat.userMessage,
272
+ ]),
273
+ );
274
+
275
+ async function sendPendingInitialChat(): Promise<void> {
276
+ try {
277
+ const token = getStoredAuthToken() ?? undefined;
278
+ const assistantMessage = await ragApi.sendConfiguredChatMessage(
279
+ {
280
+ content: activePendingInitialChat.content,
281
+ courseId: activePendingInitialChat.courseId,
282
+ sessionId,
283
+ token,
284
+ },
285
+ );
286
+
287
+ if (isCancelled) {
288
+ return;
289
+ }
290
+
291
+ clearPendingInitialChat(sessionId);
292
+ pendingInitialChatRef.current = undefined;
293
+
294
+ if (isDirectChatMode()) {
295
+ appendDirectChatMessages(sessionId, [
296
+ activePendingInitialChat.userMessage,
297
+ assistantMessage,
298
+ ]);
299
+ setMessages((currentMessages) =>
300
+ appendUniqueMessages(currentMessages, [
301
+ assistantMessage,
302
+ ]),
303
+ );
304
+
305
+ return;
306
+ }
307
+
308
+ const history = await ragApi.getChatHistory(sessionId, {
309
+ token,
310
+ });
311
+
312
+ if (!isCancelled) {
313
+ applyHistoryResponse(history);
314
+ }
315
+ } catch (error) {
316
+ if (!isCancelled) {
317
+ clearPendingInitialChat(sessionId);
318
+ pendingInitialChatRef.current = undefined;
319
+ setMessages((currentMessages) =>
320
+ currentMessages.filter(
321
+ (message) =>
322
+ message.uuid_id !==
323
+ activePendingInitialChat.userMessage.uuid_id,
324
+ ),
325
+ );
326
+ setQuestion(activePendingInitialChat.content);
327
+ setBackendMessage(getRagErrorMessage(error));
328
+ }
329
+ } finally {
330
+ if (!isCancelled) {
331
+ setIsProcessing(false);
332
+ }
333
+ }
334
+ }
335
+
336
+ void sendPendingInitialChat();
337
+
338
+ return () => {
339
+ isCancelled = true;
340
+ };
341
  }, [applyHistoryResponse, sessionId]);
342
 
343
+ const handleDeleteSession = async (
344
+ targetSessionId: string,
345
+ ): Promise<DeleteSessionResult> => {
346
  try {
347
  const token = getStoredAuthToken() ?? undefined;
348
  await ragApi.deleteChatSession(targetSessionId, token);
 
357
  if (targetSessionId === sessionId) {
358
  router.visit(mahasiswa());
359
  }
360
+
361
+ return { ok: true };
362
  } catch (error) {
363
+ const message = getRagErrorMessage(error);
364
+ setBackendMessage(message);
365
+
366
+ return { message, ok: false };
367
  }
368
  };
369
 
 
436
  activeSessionId={sessionId}
437
  chatSessions={chatSessions}
438
  isLoadingSessions={isLoading}
439
+ onDeleteSession={handleDeleteSession}
 
 
440
  studentName={studentName}
441
  >
442
  <div className="student-chat-stage student-chat-stage--session">
 
447
  </p>
448
  )}
449
 
450
+ {isLoading && messages.length === 0 && !isProcessing ? (
451
  <p className="student-chat-loading">
452
  Memuat percakapan...
453
  </p>
 
466
  <Bot className="size-4" />
467
  </span>
468
  <div className="student-message-bubble student-message-bubble--typing">
469
+ Thinking...
470
  </div>
471
  </article>
472
  )}
 
479
  <div className="student-chat-composer">
480
  <QuestionComposer
481
  courses={composerCourses}
 
482
  isLoadingCourses={isLoading}
483
  isProcessing={isProcessing}
484
  onCourseChange={setSelectedCourseId}
resources/js/pages/mahasiswa.tsx CHANGED
@@ -4,6 +4,7 @@ import type { FormEvent } from 'react';
4
  import { useEffect, useState } from 'react';
5
 
6
  import { QuestionComposer } from '@/components/student/question-composer';
 
7
  import {
8
  isAbortError,
9
  StudentShell,
@@ -12,11 +13,11 @@ import {
12
  import { getStoredAuthToken } from '@/lib/auth';
13
  import type { ChatSessionResponse, CourseResponse } from '@/lib/rag';
14
  import {
15
- appendDirectChatMessages,
16
  createLocalUserMessage,
17
  getRagErrorMessage,
18
- isDirectChatMode,
19
  ragApi,
 
20
  } from '@/lib/rag';
21
  import { show as showMahasiswaSession } from '@/routes/mahasiswa';
22
 
@@ -75,6 +76,29 @@ export default function Mahasiswa() {
75
  };
76
  }, []);
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  const handleQuestionSubmit = async (
79
  event: FormEvent<HTMLFormElement>,
80
  ): Promise<void> => {
@@ -100,20 +124,12 @@ export default function Mahasiswa() {
100
  );
101
 
102
  const userMessage = createLocalUserMessage(trimmedQuestion);
103
- const assistantMessage = await ragApi.sendConfiguredChatMessage({
104
  content: trimmedQuestion,
105
  courseId: selectedCourseId,
106
- sessionId: session.uuid_id,
107
- token,
108
  });
109
 
110
- if (isDirectChatMode()) {
111
- appendDirectChatMessages(session.uuid_id, [
112
- userMessage,
113
- assistantMessage,
114
- ]);
115
- }
116
-
117
  router.visit(showMahasiswaSession(session.uuid_id));
118
  } catch (error) {
119
  setBackendMessage(getRagErrorMessage(error));
@@ -129,6 +145,7 @@ export default function Mahasiswa() {
129
  <StudentShell
130
  chatSessions={chatSessions}
131
  isLoadingSessions={isLoading}
 
132
  studentName={studentName}
133
  >
134
  <div className="student-chat-stage">
 
4
  import { useEffect, useState } from 'react';
5
 
6
  import { QuestionComposer } from '@/components/student/question-composer';
7
+ import type { DeleteSessionResult } from '@/components/student/student-shell';
8
  import {
9
  isAbortError,
10
  StudentShell,
 
13
  import { getStoredAuthToken } from '@/lib/auth';
14
  import type { ChatSessionResponse, CourseResponse } from '@/lib/rag';
15
  import {
16
+ clearDirectChatMessages,
17
  createLocalUserMessage,
18
  getRagErrorMessage,
 
19
  ragApi,
20
+ savePendingInitialChat,
21
  } from '@/lib/rag';
22
  import { show as showMahasiswaSession } from '@/routes/mahasiswa';
23
 
 
76
  };
77
  }, []);
78
 
79
+ const handleDeleteSession = async (
80
+ targetSessionId: string,
81
+ ): Promise<DeleteSessionResult> => {
82
+ try {
83
+ const token = getStoredAuthToken() ?? undefined;
84
+
85
+ await ragApi.deleteChatSession(targetSessionId, token);
86
+ clearDirectChatMessages(targetSessionId);
87
+ setChatSessions((currentSessions) =>
88
+ currentSessions.filter(
89
+ (session) => session.uuid_id !== targetSessionId,
90
+ ),
91
+ );
92
+
93
+ return { ok: true };
94
+ } catch (error) {
95
+ const message = getRagErrorMessage(error);
96
+ setBackendMessage(message);
97
+
98
+ return { message, ok: false };
99
+ }
100
+ };
101
+
102
  const handleQuestionSubmit = async (
103
  event: FormEvent<HTMLFormElement>,
104
  ): Promise<void> => {
 
124
  );
125
 
126
  const userMessage = createLocalUserMessage(trimmedQuestion);
127
+ savePendingInitialChat(session.uuid_id, {
128
  content: trimmedQuestion,
129
  courseId: selectedCourseId,
130
+ userMessage,
 
131
  });
132
 
 
 
 
 
 
 
 
133
  router.visit(showMahasiswaSession(session.uuid_id));
134
  } catch (error) {
135
  setBackendMessage(getRagErrorMessage(error));
 
145
  <StudentShell
146
  chatSessions={chatSessions}
147
  isLoadingSessions={isLoading}
148
+ onDeleteSession={handleDeleteSession}
149
  studentName={studentName}
150
  >
151
  <div className="student-chat-stage">
routes/web.php CHANGED
@@ -20,4 +20,12 @@
20
  'sessionId' => $uuid,
21
  ]))->name('mahasiswa.show');
22
 
 
 
 
 
 
 
 
 
23
  require __DIR__.'/settings.php';
 
20
  'sessionId' => $uuid,
21
  ]))->name('mahasiswa.show');
22
 
23
+ Route::inertia('dosen', 'dosen')->name('dosen');
24
+
25
+ Route::inertia('dosen/pengaturan', 'dosen/pengaturan')->name('dosen.pengaturan');
26
+
27
+ Route::get('dosen/{courseId}', fn (string $courseId) => Inertia::render('dosen-detail', [
28
+ 'courseId' => $courseId,
29
+ ]))->name('dosen.show');
30
+
31
  require __DIR__.'/settings.php';
tests/Feature/DosenDashboardTest.php ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <?php
2
+
3
+ use Inertia\Testing\AssertableInertia as Assert;
4
+
5
+ test('dosen dashboard page can be rendered', function () {
6
+ $this->get(route('dosen'))
7
+ ->assertOk()
8
+ ->assertInertia(fn (Assert $page) => $page->component('dosen'));
9
+ });
10
+
11
+ test('dosen settings page can be rendered', function () {
12
+ $this->get(route('dosen.pengaturan'))
13
+ ->assertOk()
14
+ ->assertInertia(fn (Assert $page) => $page->component('dosen/pengaturan'));
15
+ });
16
+
17
+ test('dosen course detail page can be rendered', function () {
18
+ $this->get(route('dosen.show', ['courseId' => '101']))
19
+ ->assertOk()
20
+ ->assertInertia(fn (Assert $page) => $page
21
+ ->component('dosen-detail')
22
+ ->where('courseId', '101'));
23
+ });