@@ -55,6 +55,11 @@ interface Comment {
5555 created_at : string ;
5656}
5757
58+ interface StatusAudit {
59+ event : string ;
60+ timestamp : string ;
61+ }
62+
5863interface FacultyPost {
5964 id : number ;
6065 faculty_id : number ;
@@ -69,6 +74,7 @@ interface FacultyPost {
6974 created_at : string ;
7075 updated_at : string ;
7176 comments : Comment [ ] | null ;
77+ status_audit_logs ?: StatusAudit [ ] | null ;
7278}
7379
7480interface WardenPost {
@@ -85,6 +91,7 @@ interface WardenPost {
8591 created_at : string ;
8692 updated_at : string ;
8793 comments : Comment [ ] | null ;
94+ status_audit_logs ?: StatusAudit [ ] | null ;
8895}
8996
9097interface CentreHeadPost {
@@ -100,6 +107,7 @@ interface CentreHeadPost {
100107 created_at : string ;
101108 updated_at : string ;
102109 comments : Comment [ ] | null ;
110+ status_audit_logs ?: StatusAudit [ ] | null ;
103111}
104112
105113type Post = FacultyPost | WardenPost | CentreHeadPost ;
@@ -548,6 +556,40 @@ export function AdminPostView() {
548556 return jes . find ( ( j ) => j . id === post . assigned_je_id ) ?. email || null ;
549557 } , [ post , jes ] ) ;
550558
559+ const timelineItems = useMemo ( ( ) => {
560+ if ( ! post ) return [ ] ;
561+ const items : Array <
562+ | { type : 'comment' ; data : Comment ; date : Date }
563+ | { type : 'audit' ; data : StatusAudit ; date : Date }
564+ > = [ ] ;
565+
566+ if ( post . comments ) {
567+ post . comments . forEach ( ( c ) => {
568+ const isJEComment = jes . some ( ( je ) => c . comment_text . includes ( je . email ) ) ;
569+ if ( isJEComment && post . status !== 'pending_je' ) {
570+ return ;
571+ }
572+ items . push ( {
573+ type : 'comment' ,
574+ data : c ,
575+ date : new Date ( c . created_at ) ,
576+ } ) ;
577+ } ) ;
578+ }
579+
580+ if ( post . status_audit_logs ) {
581+ post . status_audit_logs . forEach ( ( log ) => {
582+ items . push ( {
583+ type : 'audit' ,
584+ data : log ,
585+ date : new Date ( log . timestamp ) ,
586+ } ) ;
587+ } ) ;
588+ }
589+
590+ return items . sort ( ( a , b ) => a . date . getTime ( ) - b . date . getTime ( ) ) ;
591+ } , [ post , jes ] ) ;
592+
551593 // ── Loading ──
552594 if ( loading ) {
553595 return (
@@ -599,6 +641,7 @@ export function AdminPostView() {
599641 const cp = isCentrehead ? ( post as CentreHeadPost ) : null ;
600642
601643 const comments = post . comments ?? [ ] ;
644+
602645 const roleLabel = isFaculty ? 'Faculty' : isWarden ? 'Warden' : 'Centre Head' ;
603646 const RoleIcon = isFaculty ? GraduationCap : isWarden ? BedDouble : Building2 ;
604647 const statusCls = STATUS_STYLES [ post . status . toLowerCase ( ) ] ?? 'bg-gray-100 text-gray-700 border-gray-200' ;
@@ -699,104 +742,135 @@ export function AdminPostView() {
699742 < Detail label = "Last updated" value = { formatDate ( post . updated_at ) } />
700743 </ dl >
701744
702- { /* ── Comments ── */ }
745+ { /* ── Timeline & Comments ── */ }
703746 < div className = "mt-10 pt-8 border-t border-gray-200" >
704747 < h2 className = "text-sm font-bold text-gray-900 flex items-center gap-2 mb-5" >
705748 < MessageSquare className = "w-4 h-4 text-gray-400" />
706- Comments
749+ Post Timeline & Comments
707750 < span className = "text-gray-400 font-semibold" > ({ comments . length } )</ span >
708751 < span className = "inline-flex items-center gap-1 text-[11px] text-gray-500 font-normal ml-2 bg-gray-100 px-2 py-0.5 rounded-full" >
709752 < Info className = "w-3 h-3 text-gray-400" />
710753 Comments can be updated only within 30 minutes
711754 </ span >
712755 </ h2 >
713756
714- { /* Comment list */ }
715- { comments . length === 0 ? (
716- < p className = "text-sm text-gray-400 italic mb-8" > No comments yet.</ p >
757+ { /* Combined Timeline */ }
758+ { timelineItems . length === 0 ? (
759+ < p className = "text-sm text-gray-400 italic mb-8" > No history or comments yet.</ p >
717760 ) : (
718- < ul className = "space-y-3 mb-8" >
719- { comments . map ( ( c ) => {
720- const who = c . role ? c . role . replace ( / _ / g, ' ' ) : 'Staff' ;
721- const isMyComment = adminComments . some ( ( ac ) => ac . id === c . id ) ;
722- const isEditing = editingCommentId === c . id ;
723- const isBusy = commentActionLoadingId === c . id ;
724- const editExpired = isEditWindowExpired ( c . created_at ) ;
725-
726- const isJEComment = jes . some ( ( je ) => c . comment_text . includes ( je . email ) ) ;
727- if ( isJEComment && post ?. status !== 'pending_je' ) {
728- return null ;
729- }
730-
731- return (
732- < li key = { c . id } className = "border-l-2 border-[#ff9900]/50 bg-gray-50 rounded-r-lg px-4 py-3 group/comment relative" >
733- < div className = "flex items-center gap-2 mb-1" >
734- < span className = "text-xs font-bold text-gray-800" > { who } </ span >
735- { c . email && < span className = "text-[11px] text-gray-400 truncate max-w-[150px] sm:max-w-none" > { c . email } </ span > }
736- < span className = "ml-auto text-[11px] text-gray-400 flex items-center gap-2" >
737- { formatDateTime ( c . created_at ) }
738-
739- { /* Edit/Delete actions (only shown for owner comments on hover and when not editing/expired) */ }
740- { isMyComment && ! isEditing && ! editExpired && (
741- < span className = "flex items-center gap-1 opacity-0 group-hover/comment:opacity-100 transition-opacity" >
742- < button
743- onClick = { ( ) => {
744- setEditingCommentId ( c . id ) ;
745- setEditingText ( c . comment_text ) ;
746- } }
747- disabled = { isBusy }
748- className = "p-0.5 rounded text-gray-400 hover:text-gray-700 hover:bg-gray-200/50 transition cursor-pointer"
749- title = "Edit comment"
750- >
751- < Pencil className = "w-3 h-3" />
752- </ button >
753- < button
754- onClick = { ( ) => handleDeleteComment ( c . id ) }
755- disabled = { isBusy }
756- className = "p-0.5 rounded text-gray-400 hover:text-red-600 hover:bg-red-50 transition cursor-pointer"
757- title = "Delete comment"
758- >
759- < Trash2 className = "w-3 h-3" />
760- </ button >
761- </ span >
762- ) }
761+ < div className = "relative border-l border-gray-200 ml-4 pl-6 space-y-6 mb-8" >
762+ { timelineItems . map ( ( item , idx ) => {
763+ if ( item . type === 'audit' ) {
764+ const audit = item . data ;
765+ const normEvent = audit . event . toLowerCase ( ) ;
766+ const dotColor = STATUS_DOT [ normEvent ] ?? 'bg-gray-400' ;
767+ const eventText = ( ( ) => {
768+ if ( normEvent === 'pending_xen' ) return 'Sent to XEN for review.' ;
769+ if ( normEvent === 'pending_ae' ) return 'Sent to AE for review.' ;
770+ if ( normEvent === 'resolved_ae' ) return 'Post marked as resolved by AE.' ;
771+ if ( normEvent === 'pending_je' ) return 'Sent to JE for review.' ;
772+ if ( normEvent === 'resolved_je' ) return 'Post marked as resolved by JE.' ;
773+ if ( normEvent === 'resolved_all' ) return 'Post Resolved and closed by XEN.' ;
774+ return `Status updated to ${ audit . event . replace ( / _ / g, ' ' ) } ` ;
775+ } ) ( ) ;
776+
777+ return (
778+ < div key = { `audit-${ idx } ` } className = "relative" >
779+ < span className = "absolute -left-[30px] top-1.5 flex h-3 w-3 items-center justify-center rounded-full bg-white ring-4 ring-white" >
780+ < span className = { `h-2 w-2 rounded-full ${ dotColor } ` } />
763781 </ span >
782+ < div className = "flex items-center gap-2" >
783+ < span className = "text-xs font-semibold text-gray-500" >
784+ { formatDateTime ( audit . timestamp ) }
785+ </ span >
786+ < span className = "text-sm font-bold text-gray-800" >
787+ { eventText }
788+ </ span >
789+ </ div >
764790 </ div >
791+ ) ;
792+ } else {
793+ const c = item . data ;
794+ const who = c . role ? c . role . replace ( / _ / g, ' ' ) : 'Staff' ;
795+ const isMyComment = adminComments . some ( ( ac ) => ac . id === c . id ) ;
796+ const isEditing = editingCommentId === c . id ;
797+ const isBusy = commentActionLoadingId === c . id ;
798+ const editExpired = isEditWindowExpired ( c . created_at ) ;
765799
766- { isEditing ? (
767- < div className = "mt-2" >
768- < textarea
769- value = { editingText }
770- onChange = { ( e ) => setEditingText ( e . target . value ) }
771- disabled = { isBusy }
772- rows = { 2 }
773- className = "w-full text-sm text-gray-800 bg-white border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-[#ff9900]/40 focus:border-[#ff9900] transition resize-none"
774- />
775- < div className = "mt-2 flex justify-end gap-2" >
776- < button
777- onClick = { ( ) => setEditingCommentId ( null ) }
778- disabled = { isBusy }
779- className = "border border-gray-200 text-gray-500 hover:bg-gray-100 font-bold text-[11px] px-2.5 py-1.5 rounded transition cursor-pointer"
780- >
781- Cancel
782- </ button >
783- < button
784- onClick = { ( ) => handleEditComment ( c . id ) }
785- disabled = { isBusy || ! editingText . trim ( ) }
786- className = "bg-[#2d2d2d] text-white hover:bg-[#ff9900] font-bold text-[11px] px-3 py-1.5 rounded transition disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer flex items-center gap-1"
787- >
788- { isBusy && < span className = "w-3 h-3 border-2 border-white border-t-transparent rounded-full animate-spin" /> }
789- Save
790- </ button >
800+ return (
801+ < div key = { `comment-${ c . id } ` } className = "relative" >
802+ < span className = "absolute -left-[34px] top-1 flex h-5 w-5 items-center justify-center rounded-full bg-white ring-4 ring-white" >
803+ < MessageSquare className = "w-3.5 h-3.5 text-gray-400" />
804+ </ span >
805+ < div className = "border-l-2 border-[#ff9900]/50 bg-gray-50 rounded-r-lg px-4 py-3 group/comment relative" >
806+ < div className = "flex items-center gap-2 mb-1" >
807+ < span className = "text-xs font-bold text-gray-800" > { who } </ span >
808+ { c . email && < span className = "text-[11px] text-gray-400 truncate max-w-[150px] sm:max-w-none" > { c . email } </ span > }
809+ < span className = "ml-auto text-[11px] text-gray-400 flex items-center gap-2" >
810+ { formatDateTime ( c . created_at ) }
811+
812+ { isMyComment && ! isEditing && ! editExpired && (
813+ < span className = "flex items-center gap-1 opacity-0 group-hover/comment:opacity-100 transition-opacity" >
814+ < button
815+ onClick = { ( ) => {
816+ setEditingCommentId ( c . id ) ;
817+ setEditingText ( c . comment_text ) ;
818+ } }
819+ disabled = { isBusy }
820+ className = "p-0.5 rounded text-gray-400 hover:text-gray-700 hover:bg-gray-200/50 transition cursor-pointer"
821+ title = "Edit comment"
822+ >
823+ < Pencil className = "w-3 h-3" />
824+ </ button >
825+ < button
826+ onClick = { ( ) => handleDeleteComment ( c . id ) }
827+ disabled = { isBusy }
828+ className = "p-0.5 rounded text-gray-400 hover:text-red-600 hover:bg-red-50 transition cursor-pointer"
829+ title = "Delete comment"
830+ >
831+ < Trash2 className = "w-3 h-3" />
832+ </ button >
833+ </ span >
834+ ) }
835+ </ span >
791836 </ div >
837+
838+ { isEditing ? (
839+ < div className = "mt-2" >
840+ < textarea
841+ value = { editingText }
842+ onChange = { ( e ) => setEditingText ( e . target . value ) }
843+ disabled = { isBusy }
844+ rows = { 2 }
845+ className = "w-full text-sm text-gray-800 bg-white border border-gray-200 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-[#ff9900]/40 focus:border-[#ff9900] transition resize-none"
846+ />
847+ < div className = "mt-2 flex justify-end gap-2" >
848+ < button
849+ onClick = { ( ) => setEditingCommentId ( null ) }
850+ disabled = { isBusy }
851+ className = "border border-gray-200 text-gray-500 hover:bg-gray-100 font-bold text-[11px] px-2.5 py-1.5 rounded transition cursor-pointer"
852+ >
853+ Cancel
854+ </ button >
855+ < button
856+ onClick = { ( ) => handleEditComment ( c . id ) }
857+ disabled = { isBusy || ! editingText . trim ( ) }
858+ className = "bg-[#2d2d2d] text-white hover:bg-[#ff9900] font-bold text-[11px] px-3 py-1.5 rounded transition disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer flex items-center gap-1"
859+ >
860+ { isBusy && < span className = "w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" /> }
861+ Save
862+ </ button >
863+ </ div >
864+ </ div >
865+ ) : (
866+ < p className = "text-sm text-gray-700 leading-relaxed break-words" > { c . comment_text } </ p >
867+ ) }
792868 </ div >
793- ) : (
794- < p className = "text-sm text-gray-700 leading-relaxed break-words" > { c . comment_text } </ p >
795- ) }
796- </ li >
797- ) ;
869+ </ div >
870+ ) ;
871+ }
798872 } ) }
799- </ ul >
873+ </ div >
800874 ) }
801875
802876 { /* ── Comment + action area — always shown; unlocked for any logged-in admin ── */ }
0 commit comments