Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 83 additions & 12 deletions app/src/components/ComplaintCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,24 +3,19 @@ import { createPortal } from 'react-dom';
import {
Zap, Hammer, Trash2, Pencil, X, Check, Calendar, MapPin, BedDouble,
MessageSquare, Wrench, GitBranch, ChevronRight, UserCircle, Clock,
Send, AlertCircle,
} from 'lucide-react';
import { POST_PLACES } from '../constants/models';

// ── Types ─────────────────────────────────────────────────────────────────────

export type Role = 'faculty' | 'warden' | 'centrehead';

export interface CommentAuthor {
id: number;
email: string;
position: string;
}

export interface ComplaintComment {
id: number;
comment_text: string;
author_id: number;
Author?: CommentAuthor;
email: string;
role: string;
created_at: string;
}

Expand Down Expand Up @@ -57,6 +52,9 @@ interface ComplaintCardProps {
onCancelEdit: () => void;
onSaveEdit: (postId: number) => void;
onDelete: (postId: number) => void;
// Called after the author successfully posts a comment, so the parent can
// refetch and surface the new comment.
onCommentPosted?: () => void;
}

// ── Status config ──────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -195,7 +193,7 @@ function CommentsList({ comments }: { comments: ComplaintComment[] }) {
return (
<div className="flex flex-col gap-3">
{comments.map((c, idx) => {
const author = c.Author?.position ? roleLabel(c.Author.position) : `Admin #${c.author_id}`;
const author = c.role ? roleLabel(c.role) : 'Staff';
const initials = author.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase();
return (
<div key={c.id} className="flex gap-3">
Expand All @@ -208,7 +206,10 @@ function CommentsList({ comments }: { comments: ComplaintComment[] }) {
<div className="flex-1 min-w-0">
<div className="bg-white border border-gray-200 rounded-xl rounded-tl-sm px-4 py-3 shadow-sm">
<div className="flex items-center justify-between gap-2 mb-1.5">
<span className="text-xs font-bold text-gray-800">{author}</span>
<span className="min-w-0 flex items-baseline gap-1.5">
<span className="text-xs font-bold text-gray-800">{author}</span>
{c.email && <span className="text-[10px] text-gray-400 truncate">{c.email}</span>}
</span>
<span className="inline-flex items-center gap-1 text-[10px] text-gray-400 shrink-0">
<Clock className="w-3 h-3" />
{formatDateTime(c.created_at)}
Expand All @@ -225,6 +226,74 @@ function CommentsList({ comments }: { comments: ComplaintComment[] }) {
);
}

// ── Comment composer (post author only) ──────────────────────────────────────────

function CommentComposer({ role, postId, onPosted }: { role: Role; postId: number; onPosted?: () => void }) {
const [text, setText] = useState('');
const [submitting, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);

const disabled = submitting || !text.trim();

async function submit() {
const content = text.trim();
if (!content) return;
setBusy(true);
setError(null);
try {
const res = await fetch(`/api/post/${role}/comment/${postId}`, {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Content: content }),
});
if (!res.ok) {
let msg = `Failed to post comment (${res.status})`;
try { const b = await res.json(); if (b?.error) msg = b.error; } catch {}
throw new Error(msg);
}
setText('');
onPosted?.();
} catch (err) {
setError((err as Error).message);
} finally {
setBusy(false);
}
}

return (
<div className="mt-4">
<div className="flex items-end gap-2">
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) submit(); }}
disabled={submitting}
rows={2}
placeholder="Add a comment…"
className="flex-1 text-sm text-gray-800 placeholder-gray-400 bg-white border border-gray-200 rounded-xl px-3.5 py-2.5 resize-none focus:outline-none focus:ring-2 focus:ring-gray-300 focus:border-gray-400 transition disabled:opacity-50"
/>
<button
onClick={submit}
disabled={disabled}
title="Post comment (Ctrl/⌘ + Enter)"
className="shrink-0 inline-flex items-center gap-1.5 text-xs font-bold text-white bg-gray-900 hover:bg-gray-700 px-4 py-2.5 rounded-xl transition-colors disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer"
>
{submitting
? <span className="w-3.5 h-3.5 border-2 border-white border-t-transparent rounded-full animate-spin" />
: <Send className="w-3.5 h-3.5" />}
Send
</button>
</div>
{error && (
<p className="mt-2 text-xs font-semibold text-red-500 flex items-center gap-1.5">
<AlertCircle className="w-3.5 h-3.5 shrink-0" /> {error}
</p>
)}
</div>
);
}

// ── Modal ──────────────────────────────────────────────────────────────────────

interface ModalProps extends ComplaintCardProps {
Expand All @@ -233,7 +302,7 @@ interface ModalProps extends ComplaintCardProps {

function PostModal({
post, role, isEditing, isBusy, editForm,
onEditFormChange, onStartEdit, onCancelEdit, onSaveEdit, onDelete, onClose,
onEditFormChange, onStartEdit, onCancelEdit, onSaveEdit, onDelete, onClose, onCommentPosted,
}: ModalProps) {
const isFaculty = role === 'faculty';
const isWarden = role === 'warden';
Expand Down Expand Up @@ -375,6 +444,7 @@ function PostModal({
)}
</div>
<CommentsList comments={comments} />
<CommentComposer role={role} postId={post.id} onPosted={onCommentPosted} />
</div>
)}
</div>
Expand All @@ -388,7 +458,7 @@ function PostModal({

export function ComplaintCard({
post, role, isEditing, isBusy, editForm,
onEditFormChange, onStartEdit, onCancelEdit, onSaveEdit, onDelete,
onEditFormChange, onStartEdit, onCancelEdit, onSaveEdit, onDelete, onCommentPosted,
}: ComplaintCardProps) {
const [modalOpen, setModalOpen] = useState(false);

Expand Down Expand Up @@ -489,6 +559,7 @@ export function ComplaintCard({
onSaveEdit={(id) => { onSaveEdit(id); }}
onDelete={(id) => { onDelete(id); setModalOpen(false); }}
onClose={() => { setModalOpen(false); if (isEditing) onCancelEdit(); }}
onCommentPosted={onCommentPosted}
/>
)}
</>
Expand Down
21 changes: 10 additions & 11 deletions app/src/pages/admin/AdminPostView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,17 +44,11 @@ interface CentreHeadAuthor {
phone_number: string;
}

interface CommentAuthor {
id: number;
email: string;
position: string;
}

interface Comment {
id: number;
comment_text: string;
author_id: number;
Author?: CommentAuthor;
email: string;
role: string;
created_at: string;
}

Expand Down Expand Up @@ -110,6 +104,7 @@ type Post = FacultyPost | WardenPost | CentreHeadPost;
interface ApiResponse {
success: string;
post: Post;
position: string;
}

// ── Helpers ────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -217,11 +212,13 @@ function Detail({ label, value }: { label: string; value?: string }) {

export function AdminPostView() {
const { role, post_id } = useParams<{ role: string; post_id: string }>();
const adminPosition = sessionStorage.getItem('adminPosition') ?? '';
const adminType = adminPosition.startsWith('XEN') ? 'xen' : adminPosition.startsWith('AE') ? 'ae' : adminPosition.startsWith('JE') ? 'je' : '';
const navigate = useNavigate();

const [post, setPost] = useState<Post | null>(null);
// Admin position comes from the server (AdminGetPost response) rather than
// per-tab sessionStorage, so action gating survives new tabs / direct nav.
const [adminPosition, setAdminPosition] = useState('');
const adminType = adminPosition.startsWith('XEN') ? 'xen' : adminPosition.startsWith('AE') ? 'ae' : adminPosition.startsWith('JE') ? 'je' : '';
const [loading, setLoading] = useState(true);
const [error, setError] = useState<{ message: string; status?: number } | null>(null);

Expand All @@ -247,6 +244,7 @@ export function AdminPostView() {
})
.then((json: ApiResponse) => {
setPost(json.post);
setAdminPosition(json.position ?? '');
if (!silent) setLoading(false);
})
.catch((err: Error & { status?: number }) => {
Expand Down Expand Up @@ -463,11 +461,12 @@ export function AdminPostView() {
) : (
<ul className="space-y-3 mb-8">
{comments.map((c) => {
const who = c.Author?.position ? c.Author.position.replace(/_/g, ' ') : `Admin #${c.author_id}`;
const who = c.role ? c.role.replace(/_/g, ' ') : 'Staff';
return (
<li key={c.id} className="border-l-2 border-[#ff9900]/50 bg-gray-50 rounded-r-lg px-4 py-3">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-bold text-gray-800">{who}</span>
{c.email && <span className="text-[11px] text-gray-400 truncate">{c.email}</span>}
<span className="ml-auto text-[11px] text-gray-400">{formatDateTime(c.created_at)}</span>
</div>
<p className="text-sm text-gray-700 leading-relaxed">{c.comment_text}</p>
Expand Down
1 change: 0 additions & 1 deletion app/src/pages/auth/StaffLogin.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@ export function StaffLogin() {
setStatus('error');
setMessage(`Unknown position "${data.position}" — contact admin.`);
} else {
sessionStorage.setItem('adminPosition', data.position ?? '');
navigate(dest);
}
} else {
Expand Down
9 changes: 6 additions & 3 deletions app/src/pages/profile/Profile.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import {
ShieldCheck, LogOut, PlusCircle, AlertCircle, Pencil, UserCheck,
Expand Down Expand Up @@ -38,15 +38,15 @@ export function Profile() {
});
}, [navigate]);

useEffect(() => {
const fetchPosts = useCallback((silent = false) => {
if (!profile) return;
let endpoint = '';
if ('department' in profile) endpoint = '/api/post/faculty';
else if ('hostel' in profile) endpoint = '/api/post/warden';
else if ('building' in profile) endpoint = '/api/post/centrehead';
else return;

setPostsLoading(true);
if (!silent) setPostsLoading(true);
setPostsError(null);
fetch(endpoint, { credentials: 'include' })
.then(async (res) => {
Expand All @@ -57,6 +57,8 @@ export function Profile() {
.catch((err: Error) => { setPostsError(err.message); setPostsLoading(false); });
}, [profile]);

useEffect(() => { fetchPosts(); }, [fetchPosts]);

if (loading) {
return (
<MainLayout>
Expand Down Expand Up @@ -305,6 +307,7 @@ export function Profile() {
onCancelEdit={() => setEditingId(null)}
onSaveEdit={handleSaveEdit}
onDelete={handleDelete}
onCommentPosted={() => fetchPosts(true)}
/>
))}
</div>
Expand Down
5 changes: 3 additions & 2 deletions handlers/admin_comment.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ type CommentType struct {

// AdminPost comment allow any admin comment on any type of post.
// Common for all type of admins and posts.
func (h *AdminHandler) AdminPostComment (c *gin.Context) {
func (h *AdminHandler) AdminPostComment(c *gin.Context) {
// verify the admin
emailID, exists := c.Get(middleware.EmailKey)
if !exists {
Expand Down Expand Up @@ -89,7 +89,8 @@ func (h *AdminHandler) AdminPostComment (c *gin.Context) {
CommentableID: uint(postID),
CommentableType: postType,
Content : inputs.Content,
AuthorID: admin.ID,
Email: admin.Email,
Role: string(admin.Position),
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
Expand Down
30 changes: 11 additions & 19 deletions handlers/admin_post.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (

// GetXENPosts fetch posts (all that were posted) where status of post is
// Pending_XEN PENDING_AE PENDING_JE Resolved_JE Resolved Closed
func (h *AdminHandler) GetXENPosts (c *gin.Context) {
func (h *AdminHandler) GetXENPosts(c *gin.Context) {

emailID, exists := c.Get(middleware.EmailKey)
if !exists {
Expand Down Expand Up @@ -86,7 +86,7 @@ func (h *AdminHandler) GetXENPosts (c *gin.Context) {

// GetAEPosts fetch posts where status of post is
// Pending_AE Pending_JE
func (h* AdminHandler) GetAEPosts (c *gin.Context) {
func (h* AdminHandler) GetAEPosts(c *gin.Context) {

// get email of user from gin context
email, exists := c.Get(middleware.EmailKey)
Expand Down Expand Up @@ -161,7 +161,7 @@ func (h* AdminHandler) GetAEPosts (c *gin.Context) {

// GetJEPosts fetch posts where status of Post is
// Pending_JE Resolved_JE
func (h* AdminHandler) GetJEPosts (c *gin.Context) {
func (h* AdminHandler) GetJEPosts(c *gin.Context) {

// get email of user from gin context
email, exists := c.Get(middleware.EmailKey)
Expand Down Expand Up @@ -271,11 +271,7 @@ func (h *AdminHandler) AdminGetPost(c *gin.Context) {
result := h.DB.Preload("Author", func (db *gorm.DB) (*gorm.DB) {
return db.Select("id, email, name, house_number, department, phone_number, block, type")
}).
Preload("Comments", func(db *gorm.DB) (*gorm.DB) {
return db.Preload("Author", func(d *gorm.DB) (*gorm.DB) {
return d.Select("id, email, position")
})
}).
Preload("Comments").
Where("id = ?", postID).Take(&post)
if result.Error != nil {
c.JSON(404, gin.H{"error": "failed to fetch the post"})
Expand All @@ -287,11 +283,7 @@ func (h *AdminHandler) AdminGetPost(c *gin.Context) {
result := h.DB.Preload("Author", func (db *gorm.DB) (*gorm.DB) {
return db.Select("id, email, hostel, phone_number")
}).
Preload("Comments", func(db *gorm.DB) (*gorm.DB) {
return db.Preload("Author", func(d *gorm.DB) (*gorm.DB) {
return d.Select("id, email, position")
})
}).
Preload("Comments").
Where("id = ?", postID).Take(&post)
if result.Error != nil {
c.JSON(404, gin.H{"error": "failed to fetch the post"})
Expand All @@ -303,11 +295,7 @@ func (h *AdminHandler) AdminGetPost(c *gin.Context) {
result := h.DB.Preload("Author", func (db *gorm.DB) (*gorm.DB) {
return db.Select("id, email, building, phone_number")
}).
Preload("Comments", func(db *gorm.DB) (*gorm.DB) {
return db.Preload("Author", func(d *gorm.DB) (*gorm.DB) {
return d.Select("id, email, position")
})
}).
Preload("Comments").
Where("id = ?", postID).Take(&post)
if result.Error != nil {
c.JSON(404, gin.H{"error": "failed to fetch the post"})
Expand All @@ -319,5 +307,9 @@ func (h *AdminHandler) AdminGetPost(c *gin.Context) {
return
}

c.JSON(200, gin.H{"success": "post fetched", "post": reqPost})
c.JSON(200, gin.H{
"success": "post fetched",
"post": reqPost,
"position": admin.Position,
})
}
Loading
Loading