feat: 게시판 댓글/답글 시스템 및 대시보드 개선

주요 변경사항:
- 게시판 계층형 댓글/답글 시스템 구현
  - DB: root_idx, depth, thread_path, is_comment, reply_count 컬럼 추가
  - 트리거: 댓글 개수 자동 업데이트
  - 답글(is_comment=false)은 목록에 표시, 댓글(is_comment=true)은 뷰어에만 표시
  - ESC 키로 모달 닫기 기능

- 업무일지 개선
  - 프로젝트 선택 시 최종 설정 자동 불러오기
  - 복사 시 jobgrp, tag 포함
  - 완료(보고) 상태 프로젝트도 검색 가능하도록 수정

- 대시보드 개선
  - 할일 목록 페이징 추가 (6개씩)
  - 할일에 요청자 정보 표시 (제목 좌측에 괄호로)
This commit is contained in:
backuppc
2025-12-03 10:10:29 +09:00
parent 3d53a5c42f
commit c1c615fe1b
86 changed files with 10612 additions and 36377 deletions

View File

@@ -0,0 +1,751 @@
import { useState, useEffect } from 'react';
import { FileText, Search, RefreshCw, Calendar, Edit3, User, Plus } from 'lucide-react';
import { comms } from '@/communication';
import { BoardItem } from '@/types';
interface BoardListProps {
bidx: number;
title: string;
icon?: React.ReactNode;
defaultCategory?: string;
categories?: { value: string; label: string; color: string }[];
}
export function BoardList({
bidx,
title,
icon = <FileText className="w-5 h-5" />,
defaultCategory = 'PATCH',
categories = [
{ value: 'PATCH', label: 'PATCH', color: 'red' },
{ value: 'UPDATE', label: 'UPDATE', color: 'lime' }
]
}: BoardListProps) {
const [boardList, setBoardList] = useState<BoardItem[]>([]);
const [loading, setLoading] = useState(false);
const [searchKey, setSearchKey] = useState('');
const [selectedItem, setSelectedItem] = useState<BoardItem | null>(null);
const [showModal, setShowModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showReplyModal, setShowReplyModal] = useState(false);
const [editFormData, setEditFormData] = useState<BoardItem | null>(null);
const [userLevel, setUserLevel] = useState(0);
const [userId, setUserId] = useState('');
const [replies, setReplies] = useState<BoardItem[]>([]); // 댓글 목록 (is_comment=true)
const [replyPosts, setReplyPosts] = useState<BoardItem[]>([]); // 답글 목록 (is_comment=false)
const [commentText, setCommentText] = useState('');
const [replyFormData, setReplyFormData] = useState<{ title: string; contents: string }>({ title: '', contents: '' });
useEffect(() => {
loadUserInfo();
loadData();
}, []);
// ESC 키 처리
useEffect(() => {
const handleEscKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
if (showReplyModal) {
setShowReplyModal(false);
} else if (showEditModal) {
setShowEditModal(false);
} else if (showModal) {
setShowModal(false);
}
}
};
document.addEventListener('keydown', handleEscKey);
return () => document.removeEventListener('keydown', handleEscKey);
}, [showModal, showEditModal, showReplyModal]);
const loadUserInfo = async () => {
try {
const response = await comms.checkLoginStatus();
if (response.Success && response.User) {
setUserLevel(response.User.Level);
setUserId(response.User.Id);
}
} catch (error) {
console.error('사용자 정보 로드 오류:', error);
}
};
const loadData = async () => {
setLoading(true);
try {
console.log('게시판 조회:', { bidx, searchKey });
const response = await comms.getBoardList(bidx, searchKey);
console.log('게시판 응답:', response);
if (response.Success && response.Data) {
setBoardList(response.Data);
} else {
console.warn('게시판 없음:', response.Message);
setBoardList([]);
}
} catch (error) {
console.error('게시판 로드 오류:', error);
alert('데이터를 불러오는 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
};
const handleSearch = () => {
loadData();
};
const loadReplies = async (rootIdx: number) => {
try {
const response = await comms.getBoardReplies(rootIdx);
if (response.Success && response.Data) {
setReplies(response.Data);
} else {
setReplies([]);
}
} catch (error) {
console.error('댓글 로드 오류:', error);
setReplies([]);
}
};
const loadReplyPosts = async (rootIdx: number) => {
try {
// 목록에서 해당 root_idx의 답글들만 필터링
const replyList = boardList.filter(item => item.root_idx === rootIdx && !item.is_comment);
setReplyPosts(replyList);
} catch (error) {
console.error('답글 로드 오류:', error);
setReplyPosts([]);
}
};
const handleAddComment = async () => {
if (!selectedItem || !commentText.trim()) return;
try {
const response = await comms.addBoardReply(selectedItem.idx, selectedItem.idx, '', commentText, true);
if (response.Success) {
setCommentText('');
loadReplies(selectedItem.idx);
// reply_count 업데이트를 위해 목록 새로고침
loadData();
} else {
alert(response.Message || '댓글 등록에 실패했습니다.');
}
} catch (error) {
console.error('댓글 등록 오류:', error);
alert('댓글 등록 중 오류가 발생했습니다.');
}
};
const handleAddReply = async () => {
if (!selectedItem || !replyFormData.title.trim() || !replyFormData.contents.trim()) {
alert('제목과 내용을 입력해주세요.');
return;
}
try {
// 답글은 is_comment=false, title 포함
const rootIdx = selectedItem.root_idx || selectedItem.idx;
const response = await comms.addBoardReply(rootIdx, selectedItem.idx, replyFormData.title, replyFormData.contents, false);
if (response.Success) {
setShowReplyModal(false);
setReplyFormData({ title: '', contents: '' });
await loadData(); // 목록 새로고침
loadReplyPosts(rootIdx); // 답글 목록 새로고침
} else {
alert(response.Message || '답글 등록에 실패했습니다.');
}
} catch (error) {
console.error('답글 등록 오류:', error);
alert('답글 등록 중 오류가 발생했습니다.');
}
};
const handleRowClick = async (item: BoardItem) => {
try {
const response = await comms.getBoardDetail(item.idx);
if (response.Success && response.Data) {
setSelectedItem(response.Data);
const rootIdx = response.Data.root_idx || response.Data.idx;
loadReplies(rootIdx); // 댓글 로드
loadReplyPosts(rootIdx); // 답글 로드
setShowModal(true); // 모두 뷰어로 보기
}
} catch (error) {
console.error('상세 조회 오류:', error);
alert('데이터를 불러오는 중 오류가 발생했습니다.');
}
};
const handleEditClick = () => {
if (selectedItem) {
setEditFormData(selectedItem);
setShowModal(false);
setShowEditModal(true);
}
};
const handleEditSave = async () => {
if (!editFormData) return;
try {
const isNew = editFormData.idx === 0;
if (isNew) {
// 신규 등록
const response = await comms.addBoard(
bidx,
editFormData.header || '',
editFormData.cate || '',
editFormData.title || '',
editFormData.contents || ''
);
if (response.Success) {
setShowEditModal(false);
setEditFormData(null);
loadData();
} else {
alert(response.Message || '등록에 실패했습니다.');
}
} else {
// 수정
const response = await comms.editBoard(
editFormData.idx,
editFormData.header || '',
editFormData.cate || '',
editFormData.title || '',
editFormData.contents || ''
);
if (response.Success) {
setShowEditModal(false);
setEditFormData(null);
loadData();
} else {
alert(response.Message || '수정에 실패했습니다.');
}
}
} catch (error) {
console.error('저장 오류:', error);
alert('저장 중 오류가 발생했습니다.');
}
};
const handleDelete = async () => {
if (!editFormData || editFormData.idx === 0) return;
if (!confirm('정말 삭제하시겠습니까?')) return;
try {
const response = await comms.deleteBoard(editFormData.idx);
if (response.Success) {
setShowEditModal(false);
setEditFormData(null);
loadData();
} else {
alert(response.Message || '삭제에 실패했습니다.');
}
} catch (error) {
console.error('삭제 오류:', error);
alert('삭제 중 오류가 발생했습니다.');
}
};
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-';
try {
const date = new Date(dateStr);
const yy = String(date.getFullYear()).slice(-2);
const mm = String(date.getMonth() + 1).padStart(2, '0');
const dd = String(date.getDate()).padStart(2, '0');
return `${yy}.${mm}.${dd}`;
} catch {
return dateStr;
}
};
const isNew = (dateStr: string | null) => {
if (!dateStr) return false;
try {
const date = new Date(dateStr);
const now = new Date();
const diffTime = now.getTime() - date.getTime();
const diffDays = diffTime / (1000 * 60 * 60 * 24);
return diffDays <= 3;
} catch {
return false;
}
};
const getCategoryColor = (cate: string) => {
const category = categories.find(c => c.value.toUpperCase() === cate.toUpperCase());
if (!category) return 'bg-gray-500/20 text-gray-400';
switch (category.color) {
case 'lime': return 'bg-lime-500/20 text-lime-400';
case 'red': return 'bg-red-500/20 text-red-400';
case 'blue': return 'bg-blue-500/20 text-blue-400';
case 'yellow': return 'bg-yellow-500/20 text-yellow-400';
default: return 'bg-gray-500/20 text-gray-400';
}
};
return (
<div className="space-y-6 animate-fade-in">
{/* 검색 필터 */}
<div className="glass-effect rounded-2xl p-6">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 flex-1">
<label className="text-white/70 text-sm font-medium whitespace-nowrap"></label>
<input
type="text"
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="제목, 내용, 작성자 등"
className="flex-1 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
</div>
<button
onClick={handleSearch}
disabled={loading}
className="h-10 bg-primary-500 hover:bg-primary-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center disabled:opacity-50"
>
{loading ? (
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
) : (
<Search className="w-4 h-4 mr-2" />
)}
</button>
<button
onClick={() => {
setEditFormData({
idx: 0,
bidx: bidx,
gcode: '',
header: '',
cate: defaultCategory,
title: '',
contents: '',
file: '',
guid: '',
url: '',
wuid: '',
wuid_name: '',
wdate: null,
project: '',
pidx: 0,
close: false,
remark: ''
});
setShowEditModal(true);
}}
disabled={!(userLevel >= 9 || userId === '395552')}
className="h-10 bg-green-500 hover:bg-green-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed"
>
<Plus className="w-4 h-4 mr-2" />
</button>
</div>
</div>
{/* 게시판 목록 */}
<div className="glass-effect rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
<h3 className="text-lg font-semibold text-white flex items-center">
{icon}
<span className="ml-2">{title}</span>
</h3>
<span className="text-white/60 text-sm">{boardList.length}</span>
</div>
<div className="divide-y divide-white/10 max-h-[calc(100vh-300px)] overflow-y-auto">
{loading ? (
<div className="px-6 py-8 text-center">
<div className="flex items-center justify-center">
<RefreshCw className="w-5 h-5 mr-2 animate-spin text-white/50" />
<span className="text-white/50"> ...</span>
</div>
</div>
) : boardList.length === 0 ? (
<div className="px-6 py-8 text-center">
<FileText className="w-12 h-12 mx-auto mb-3 text-white/30" />
<p className="text-white/50"> .</p>
</div>
) : (
boardList.map((item) => (
<div
key={item.idx}
className="px-6 py-3 hover:bg-white/5 transition-colors cursor-pointer"
onClick={() => handleRowClick(item)}
style={{ paddingLeft: `${24 + (item.depth || 0) * 24}px` }}
>
<div className="flex items-center gap-3">
<div className="flex items-center gap-2 flex-shrink-0">
{item.depth && item.depth > 0 && (
<span className="text-white/40 text-xs mr-1"></span>
)}
{item.cate && (
<span className={`px-2 py-0.5 text-xs rounded whitespace-nowrap ${getCategoryColor(item.cate)}`}>
{item.cate}
</span>
)}
{item.header && (
<span className="px-2 py-0.5 bg-primary-500/20 text-primary-400 text-xs rounded whitespace-nowrap">
{item.header}
</span>
)}
</div>
<div className="flex items-center text-white/60 text-xs flex-shrink-0 mr-3">
<Calendar className="w-3 h-3 mr-1" />
{formatDate(item.wdate)}
</div>
<h4 className="text-white font-medium flex-1 min-w-0 flex items-center gap-2">
<span className="truncate">{item.title || '(댓글)'}</span>
{isNew(item.wdate) && (
<span className="px-1.5 py-0.5 bg-yellow-500 text-white text-[10px] rounded font-bold animate-pulse flex-shrink-0">
NEW
</span>
)}
{(item.reply_count ?? 0) > 0 && (
<span className="px-1.5 py-0.5 bg-blue-500/20 text-blue-400 text-[10px] rounded flex-shrink-0">
💬 {item.reply_count}
</span>
)}
</h4>
<div className="flex items-center text-white/60 text-xs flex-shrink-0">
<User className="w-3 h-3 mr-1" />
{item.wuid_name || item.wuid}
</div>
</div>
</div>
))
)}
</div>
</div>
{/* 상세 모달 */}
{showModal && selectedItem && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-gray-900 rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden border border-white/10">
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
<div className="flex items-center gap-2">
{selectedItem.header && (
<span className="px-2 py-1 bg-primary-500/20 text-primary-400 text-sm rounded">
{selectedItem.header}
</span>
)}
{selectedItem.cate && (
<span className={`px-2 py-1 text-sm rounded ${getCategoryColor(selectedItem.cate)}`}>
{selectedItem.cate}
</span>
)}
<h2 className="text-xl font-bold text-white ml-2">{selectedItem.title}</h2>
</div>
<button
onClick={() => setShowModal(false)}
className="text-white/50 hover:text-white transition-colors"
>
<span className="text-2xl">×</span>
</button>
</div>
<div className="px-6 py-4 border-b border-white/10 flex items-center gap-4 text-sm text-white/60">
<div className="flex items-center">
<User className="w-4 h-4 mr-1" />
{selectedItem.wuid_name || selectedItem.wuid}
</div>
<div className="flex items-center">
<Calendar className="w-4 h-4 mr-1" />
{formatDate(selectedItem.wdate)}
</div>
</div>
<div className="overflow-y-auto max-h-[calc(90vh-400px)] p-6">
{selectedItem.contents && selectedItem.contents.trim() && (
<div className="prose prose-invert max-w-none mb-8">
<div className="text-white whitespace-pre-wrap">{selectedItem.contents}</div>
</div>
)}
{/* 답글 목록 */}
{replyPosts.length > 0 && (
<div className={selectedItem.contents && selectedItem.contents.trim() ? "border-t border-white/10 pt-6 mb-6" : "mb-6"}>
<h3 className="text-lg font-semibold text-white mb-4">
{replyPosts.length}
</h3>
<div className="space-y-3">
{replyPosts.map((replyPost) => (
<div
key={replyPost.idx}
className="bg-white/5 hover:bg-white/10 rounded-lg p-4 cursor-pointer transition-colors"
onClick={() => handleRowClick(replyPost)}
>
<div className="flex items-center justify-between mb-2">
<h4 className="text-white font-medium flex items-center gap-2">
{replyPost.depth && replyPost.depth > 0 && (
<span className="text-white/40 text-sm"></span>
)}
<span>{replyPost.title}</span>
{isNew(replyPost.wdate) && (
<span className="px-1.5 py-0.5 bg-yellow-500 text-white text-[10px] rounded font-bold">
NEW
</span>
)}
</h4>
<div className="flex items-center gap-3 text-xs text-white/60">
<div className="flex items-center">
<User className="w-3 h-3 mr-1" />
{replyPost.wuid_name || replyPost.wuid}
</div>
<div className="flex items-center">
<Calendar className="w-3 h-3 mr-1" />
{formatDate(replyPost.wdate)}
</div>
</div>
</div>
{replyPost.contents && (
<div className="text-white/60 text-sm line-clamp-2">
{replyPost.contents}
</div>
)}
</div>
))}
</div>
</div>
)}
{/* 댓글 목록 */}
<div className={(selectedItem.contents && selectedItem.contents.trim()) || replyPosts.length > 0 ? "border-t border-white/10 pt-6" : ""}>
<h3 className="text-lg font-semibold text-white mb-4">
{replies.length}
</h3>
<div className="space-y-4 mb-6">
{replies.map((reply) => (
<div key={reply.idx} className="bg-white/5 rounded-lg p-4">
<div className="flex items-center gap-2 mb-2">
<User className="w-4 h-4 text-white/60" />
<span className="text-sm text-white/80">{reply.wuid_name || reply.wuid}</span>
<span className="text-xs text-white/50">{formatDate(reply.wdate)}</span>
</div>
<div className="text-white/70 text-sm whitespace-pre-wrap">{reply.contents}</div>
</div>
))}
</div>
{/* 댓글 입력 */}
<div className="bg-white/5 rounded-lg p-4">
<textarea
value={commentText}
onChange={(e) => setCommentText(e.target.value)}
placeholder="댓글을 입력하세요..."
rows={3}
className="w-full bg-white/10 border border-white/30 rounded-lg px-3 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 resize-none"
/>
<div className="flex justify-end mt-2">
<button
onClick={handleAddComment}
disabled={!commentText.trim()}
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
>
</button>
</div>
</div>
</div>
</div>
<div className="flex items-center justify-between px-6 py-4 border-t border-white/10 bg-white/5">
<div className="flex items-center gap-2">
<button
onClick={() => setShowReplyModal(true)}
className="px-4 py-2 rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors"
>
</button>
</div>
<div className="flex items-center gap-2">
{(userLevel >= 9 || userId === '395552') && (
<button
onClick={handleEditClick}
className="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white transition-colors flex items-center"
>
<Edit3 className="w-4 h-4 mr-2" />
</button>
)}
<button
onClick={() => setShowModal(false)}
className="px-4 py-2 rounded-lg bg-white/10 hover:bg-white/20 text-white transition-colors"
>
</button>
</div>
</div>
</div>
</div>
)}
{/* 편집 모달 */}
{showEditModal && editFormData && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-gray-900 rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden border border-white/10">
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
<h2 className="text-xl font-bold text-white flex items-center">
<Edit3 className="w-5 h-5 mr-2" />
{editFormData.idx === 0 ? `${title} 등록` : `${title} 편집`}
</h2>
<button
onClick={() => setShowEditModal(false)}
className="text-white/50 hover:text-white transition-colors"
>
<span className="text-2xl">×</span>
</button>
</div>
<div className="overflow-y-auto max-h-[calc(90vh-180px)] p-6 space-y-4">
<div className="flex items-center gap-3">
<div className="w-32">
<label className="block text-white/70 text-xs font-medium mb-1"></label>
<select
value={editFormData.cate || defaultCategory}
onChange={(e) => setEditFormData({ ...editFormData, cate: e.target.value })}
className="w-full h-9 bg-white/10 border border-white/30 rounded-lg px-2 text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-400"
>
{categories.map((cat) => (
<option key={cat.value} value={cat.value} className="bg-gray-800">
{cat.label}
</option>
))}
</select>
</div>
<div className="flex-1">
<label className="block text-white/70 text-xs font-medium mb-1"></label>
<input
type="text"
value={editFormData.title || ''}
onChange={(e) => setEditFormData({ ...editFormData, title: e.target.value })}
className="w-full h-9 bg-white/10 border border-white/30 rounded-lg px-3 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
placeholder="제목"
/>
</div>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2"></label>
<textarea
value={editFormData.contents || ''}
onChange={(e) => setEditFormData({ ...editFormData, contents: e.target.value })}
rows={15}
className="w-full bg-white/10 border border-white/30 rounded-lg px-3 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 resize-none"
placeholder="내용을 입력하세요..."
/>
</div>
</div>
<div className="flex items-center justify-between px-6 py-4 border-t border-white/10 bg-white/5">
<div>
{editFormData && editFormData.idx > 0 && (
<button
onClick={handleDelete}
className="px-4 py-2 rounded-lg bg-red-500 hover:bg-red-600 text-white transition-colors"
>
</button>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setShowEditModal(false)}
className="px-4 py-2 rounded-lg bg-white/10 hover:bg-white/20 text-white transition-colors"
>
</button>
<button
onClick={handleEditSave}
className="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white transition-colors"
>
</button>
</div>
</div>
</div>
</div>
)}
{/* 답글 달기 모달 */}
{showReplyModal && selectedItem && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-gray-900 rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden border border-white/10">
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
<h2 className="text-xl font-bold text-white flex items-center">
<Edit3 className="w-5 h-5 mr-2" />
</h2>
<button
onClick={() => setShowReplyModal(false)}
className="text-white/50 hover:text-white transition-colors"
>
<span className="text-2xl">×</span>
</button>
</div>
<div className="overflow-y-auto max-h-[calc(90vh-180px)] p-6 space-y-4">
<div className="bg-white/5 rounded-lg p-4 mb-4">
<div className="text-sm text-white/60 mb-2"></div>
<div className="text-white font-medium">{selectedItem.title}</div>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2"> </label>
<input
type="text"
value={replyFormData.title}
onChange={(e) => setReplyFormData({ ...replyFormData, title: e.target.value })}
className="w-full h-10 bg-white/10 border border-white/30 rounded-lg px-3 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
placeholder="답글 제목을 입력하세요"
/>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2"> </label>
<textarea
value={replyFormData.contents}
onChange={(e) => setReplyFormData({ ...replyFormData, contents: e.target.value })}
rows={15}
className="w-full bg-white/10 border border-white/30 rounded-lg px-3 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 resize-none"
placeholder="답글 내용을 입력하세요..."
/>
</div>
</div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-white/10 bg-white/5">
<button
onClick={() => setShowReplyModal(false)}
className="px-4 py-2 rounded-lg bg-white/10 hover:bg-white/20 text-white transition-colors"
>
</button>
<button
onClick={handleAddReply}
className="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white transition-colors"
>
</button>
</div>
</div>
</div>
)}
</div>
);
}