Skip to content

Commit ee8bd65

Browse files
authored
feat: add status audit logs to track history (#95)
* feat: add status audit logs to track history * fix: include timestamps as well * fix: default event log while post is created * fix: append logic updated * fix: tests updated * fix: status typos updated * fix: duplicate status pass
1 parent cc77e3e commit ee8bd65

7 files changed

Lines changed: 617 additions & 87 deletions

File tree

app/src/pages/admin/AdminPostView.tsx

Lines changed: 155 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ interface Comment {
5555
created_at: string;
5656
}
5757

58+
interface StatusAudit {
59+
event: string;
60+
timestamp: string;
61+
}
62+
5863
interface 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

7480
interface 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

9097
interface 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

105113
type 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 &amp; 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

Comments
 (0)