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:
@@ -3,6 +3,7 @@ import { HashRouter, Routes, Route } from 'react-router-dom';
|
||||
import { Layout } from '@/components/layout';
|
||||
import { Dashboard, Todo, Kuntae, Jobreport, Project, Login, CommonCodePage, ItemsPage, UserListPage, MonthlyWorkPage, MailFormPage, UserAuthPage, Note } from '@/pages';
|
||||
import { PatchList } from '@/pages/PatchList';
|
||||
import { BugReport } from '@/pages/BugReport';
|
||||
import { MailList } from '@/pages/MailList';
|
||||
import { Customs } from '@/pages/Customs';
|
||||
import { comms } from '@/communication';
|
||||
@@ -98,6 +99,7 @@ export default function App() {
|
||||
<Route path="/mail-form" element={<MailFormPage />} />
|
||||
<Route path="/note" element={<Note />} />
|
||||
<Route path="/patch-list" element={<PatchList />} />
|
||||
<Route path="/bug-report" element={<BugReport />} />
|
||||
<Route path="/mail-list" element={<MailList />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -1314,6 +1314,37 @@ class CommunicationLayer {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시판 댓글/답글 목록 조회
|
||||
* @param rootIdx 원글 인덱스
|
||||
* @returns ApiResponse<BoardItem[]>
|
||||
*/
|
||||
public async getBoardReplies(rootIdx: number): Promise<ApiResponse<BoardItem[]>> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Board_GetReplies(rootIdx);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse<BoardItem[]>>('BOARD_GET_REPLIES', 'BOARD_REPLIES_DATA', { rootIdx });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 게시판 댓글/답글 추가
|
||||
* @param rootIdx 원글 인덱스
|
||||
* @param pidx 부모 글 인덱스
|
||||
* @param contents 댓글 내용
|
||||
* @param isComment 댓글 타입 (true: 댓글, false: 답글)
|
||||
* @returns ApiResponse
|
||||
*/
|
||||
public async addBoardReply(rootIdx: number, pidx: number, title: string, contents: string, isComment: boolean = true): Promise<ApiResponse> {
|
||||
if (isWebView && machine) {
|
||||
const result = await machine.Board_AddReply(rootIdx, pidx, title, contents, isComment);
|
||||
return JSON.parse(result);
|
||||
} else {
|
||||
return this.wsRequest<ApiResponse>('BOARD_ADD_REPLY', 'BOARD_REPLY_ADDED', { rootIdx, pidx, title, contents, isComment });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일 발신 내역 조회
|
||||
* @param startDate 시작일 (yyyy-MM-dd)
|
||||
|
||||
@@ -118,6 +118,58 @@ export function JobreportEditModal({
|
||||
}
|
||||
}, [isOpen, loadCommonCodes]);
|
||||
|
||||
// 모달 열릴 때 프로젝트가 설정되어 있으면 자동으로 최종 설정 불러오기
|
||||
useEffect(() => {
|
||||
const loadLastSettings = async () => {
|
||||
// 신규 등록이고, 프로젝트가 설정되어 있으며, 기본값이 비어있는 경우에만 자동 로드
|
||||
if (isOpen && !editingItem && formData.pidx && formData.pidx > 0) {
|
||||
// 이미 설정된 값이 있으면 자동 로드하지 않음 (복사 기능 등에서 이미 값이 있을 수 있음)
|
||||
const hasExistingSettings = formData.requestpart || formData.package || formData.type || formData.process;
|
||||
if (hasExistingSettings) {
|
||||
setPreviousProjectIdx(formData.pidx);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const lastReport = await comms.getLastJobReportByProject(formData.pidx, formData.projectName);
|
||||
if (lastReport.Success && lastReport.Data) {
|
||||
const updatedFormData = { ...formData };
|
||||
|
||||
if (lastReport.Data.requestpart) {
|
||||
updatedFormData.requestpart = lastReport.Data.requestpart;
|
||||
}
|
||||
if (lastReport.Data.package) {
|
||||
updatedFormData.package = lastReport.Data.package;
|
||||
}
|
||||
if (lastReport.Data.type) {
|
||||
updatedFormData.type = lastReport.Data.type;
|
||||
}
|
||||
if (lastReport.Data.jobgrp) {
|
||||
updatedFormData.jobgrp = lastReport.Data.jobgrp;
|
||||
}
|
||||
if (lastReport.Data.process) {
|
||||
updatedFormData.process = lastReport.Data.process;
|
||||
}
|
||||
if (lastReport.Data.status) {
|
||||
updatedFormData.status = lastReport.Data.status;
|
||||
}
|
||||
|
||||
onFormChange(updatedFormData);
|
||||
}
|
||||
setPreviousProjectIdx(formData.pidx);
|
||||
} catch (error) {
|
||||
console.error('최종 설정 불러오기 오류:', error);
|
||||
setPreviousProjectIdx(formData.pidx);
|
||||
}
|
||||
} else if (isOpen && !editingItem) {
|
||||
// 신규 등록인데 프로젝트가 없으면 초기화
|
||||
setPreviousProjectIdx(null);
|
||||
}
|
||||
};
|
||||
|
||||
loadLastSettings();
|
||||
}, [isOpen, editingItem]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleFieldChange = <K extends keyof JobreportFormData>(
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
AlertTriangle,
|
||||
Building,
|
||||
Star,
|
||||
Bug,
|
||||
} from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { UserInfoDialog } from '@/components/user/UserInfoDialog';
|
||||
@@ -123,6 +124,7 @@ const dropdownMenus: DropdownMenuConfig[] = [
|
||||
items: [
|
||||
{ type: 'link', path: '/note', icon: FileText, label: '메모장' },
|
||||
{ type: 'link', path: '/patch-list', icon: FileText, label: '패치 내역' },
|
||||
{ type: 'link', path: '/bug-report', icon: Bug, label: '버그 신고' },
|
||||
{ type: 'link', path: '/mail-list', icon: Mail, label: '메일 내역' },
|
||||
],
|
||||
},
|
||||
|
||||
751
Project/frontend/src/pages/BoardList.tsx
Normal file
751
Project/frontend/src/pages/BoardList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
18
Project/frontend/src/pages/BugReport.tsx
Normal file
18
Project/frontend/src/pages/BugReport.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import { BoardList } from './BoardList';
|
||||
import { Bug } from 'lucide-react';
|
||||
|
||||
export function BugReport() {
|
||||
return (
|
||||
<BoardList
|
||||
bidx={2}
|
||||
title="버그 신고"
|
||||
icon={<Bug className="w-5 h-5" />}
|
||||
defaultCategory="BUG"
|
||||
categories={[
|
||||
{ value: 'BUG', label: 'BUG', color: 'red' },
|
||||
{ value: 'FIXED', label: 'FIXED', color: 'lime' },
|
||||
{ value: 'PENDING', label: 'PENDING', color: 'yellow' }
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -64,6 +64,8 @@ export function Dashboard() {
|
||||
|
||||
// 목록 데이터
|
||||
const [urgentTodos, setUrgentTodos] = useState<TodoModel[]>([]);
|
||||
const [allUrgentTodos, setAllUrgentTodos] = useState<TodoModel[]>([]); // 전체 할일 목록
|
||||
const [todoPage, setTodoPage] = useState(1); // 할일 페이지
|
||||
const [purchaseNRList, setPurchaseNRList] = useState<PurchaseItem[]>([]);
|
||||
const [purchaseCRList, setPurchaseCRList] = useState<PurchaseItem[]>([]);
|
||||
const [recentNotes, setRecentNotes] = useState<NoteItem[]>([]);
|
||||
@@ -92,6 +94,13 @@ export function Dashboard() {
|
||||
status: '0' as TodoStatus,
|
||||
});
|
||||
|
||||
// 할일 페이지 변경 시 목록 업데이트
|
||||
useEffect(() => {
|
||||
const start = (todoPage - 1) * 6;
|
||||
const end = start + 6;
|
||||
setUrgentTodos(allUrgentTodos.slice(start, end));
|
||||
}, [todoPage, allUrgentTodos]);
|
||||
|
||||
const loadDashboardData = useCallback(async () => {
|
||||
try {
|
||||
// 오늘 날짜 (로컬 시간 기준)
|
||||
@@ -128,7 +137,9 @@ export function Dashboard() {
|
||||
setPurchaseCR(purchaseCount.CR);
|
||||
|
||||
if (urgentTodosResponse.Success && urgentTodosResponse.Data) {
|
||||
setUrgentTodos(urgentTodosResponse.Data.slice(0, 5));
|
||||
setAllUrgentTodos(urgentTodosResponse.Data);
|
||||
setUrgentTodos(urgentTodosResponse.Data.slice(0, 6));
|
||||
setTodoPage(1);
|
||||
}
|
||||
|
||||
if (allTodosResponse.Success && allTodosResponse.Data) {
|
||||
@@ -559,20 +570,25 @@ export function Dashboard() {
|
||||
onClick={() => handleTodoEdit(todo)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-4 flex-1 min-w-0">
|
||||
{todo.flag && (
|
||||
<Flag className="w-4 h-4 text-warning-400" />
|
||||
<Flag className="w-4 h-4 text-warning-400 flex-shrink-0" />
|
||||
)}
|
||||
<div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-white font-medium">
|
||||
{todo.request && (
|
||||
<span className="text-xs text-primary-400 mr-2">
|
||||
({todo.request})
|
||||
</span>
|
||||
)}
|
||||
{todo.title || '제목 없음'}
|
||||
</p>
|
||||
<p className="text-white/60 text-sm line-clamp-1">
|
||||
<p className="text-white/60 text-sm line-clamp-1 mt-1">
|
||||
{todo.remark}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="flex items-center space-x-3 flex-shrink-0 ml-4">
|
||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getPriorityClass(todo.seqno)}`}>
|
||||
{getPriorityText(todo.seqno)}
|
||||
</span>
|
||||
@@ -595,6 +611,29 @@ export function Dashboard() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{allUrgentTodos.length > 6 && (
|
||||
<div className="px-6 py-3 border-t border-white/10 flex items-center justify-between">
|
||||
<span className="text-xs text-white/50">
|
||||
{(todoPage - 1) * 6 + 1}-{Math.min(todoPage * 6, allUrgentTodos.length)} / {allUrgentTodos.length}건
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => setTodoPage(p => Math.max(1, p - 1))}
|
||||
disabled={todoPage === 1}
|
||||
className="px-2 py-1 rounded bg-white/10 hover:bg-white/20 text-white/70 disabled:opacity-30 disabled:cursor-not-allowed transition-colors text-xs"
|
||||
>
|
||||
이전
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTodoPage(p => Math.min(Math.ceil(allUrgentTodos.length / 6), p + 1))}
|
||||
disabled={todoPage >= Math.ceil(allUrgentTodos.length / 6)}
|
||||
className="px-2 py-1 rounded bg-white/10 hover:bg-white/20 text-white/70 disabled:opacity-30 disabled:cursor-not-allowed transition-colors text-xs"
|
||||
>
|
||||
다음
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* NR 모달 */}
|
||||
|
||||
@@ -207,8 +207,8 @@ export function Jobreport() {
|
||||
ot: 0, // OT 초기화
|
||||
otStart: data.otStart ? data.otStart.substring(11, 16) : '18:00',
|
||||
otEnd: data.otEnd ? data.otEnd.substring(11, 16) : '20:00',
|
||||
jobgrp: '',
|
||||
tag: '',
|
||||
jobgrp: data.jobgrp || '',
|
||||
tag: data.tag || '',
|
||||
});
|
||||
setShowModal(true);
|
||||
}
|
||||
@@ -413,28 +413,28 @@ export function Jobreport() {
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
onClick={setToday}
|
||||
className="h-8 bg-white/10 hover:bg-white/20 text-white text-xs px-3 rounded-lg transition-colors whitespace-nowrap"
|
||||
className="h-10 bg-white/10 hover:bg-white/20 text-white text-xs px-2 rounded-lg transition-colors whitespace-nowrap"
|
||||
title="오늘 날짜로 설정"
|
||||
>
|
||||
오늘
|
||||
</button>
|
||||
<button
|
||||
onClick={setYesterday}
|
||||
className="h-8 bg-white/10 hover:bg-white/20 text-white text-xs px-3 rounded-lg transition-colors whitespace-nowrap"
|
||||
className="h-10 bg-white/10 hover:bg-white/20 text-white text-xs px-2 rounded-lg transition-colors whitespace-nowrap"
|
||||
title="어제 날짜로 설정"
|
||||
>
|
||||
어제
|
||||
</button>
|
||||
<button
|
||||
onClick={setThisMonth}
|
||||
className="h-8 bg-white/10 hover:bg-white/20 text-white text-xs px-3 rounded-lg transition-colors whitespace-nowrap"
|
||||
className="h-10 bg-white/10 hover:bg-white/20 text-white text-xs px-2 rounded-lg transition-colors whitespace-nowrap"
|
||||
title="이번 달 1일부터 말일까지"
|
||||
>
|
||||
이번달
|
||||
</button>
|
||||
<button
|
||||
onClick={setLastMonth}
|
||||
className="h-8 bg-white/10 hover:bg-white/20 text-white text-xs px-3 rounded-lg transition-colors whitespace-nowrap"
|
||||
className="h-10 bg-white/10 hover:bg-white/20 text-white text-xs px-2 rounded-lg transition-colors whitespace-nowrap"
|
||||
title="저번달 1일부터 말일까지"
|
||||
>
|
||||
저번달
|
||||
@@ -510,7 +510,7 @@ export function Jobreport() {
|
||||
className="h-10 bg-success-500 hover:bg-success-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
새 업무일지
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -520,14 +520,14 @@ export function Jobreport() {
|
||||
<div className="flex-shrink-0 flex flex-col gap-3 justify-center">
|
||||
<button
|
||||
onClick={() => setShowDayReportModal(true)}
|
||||
className="h-12 bg-indigo-500 hover:bg-indigo-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center whitespace-nowrap"
|
||||
className="h-10 bg-indigo-500 hover:bg-indigo-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center whitespace-nowrap"
|
||||
>
|
||||
<Calendar className="w-4 h-4 mr-2" />
|
||||
일별 집계
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowTypeReportModal(true)}
|
||||
className="h-12 bg-purple-500 hover:bg-purple-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center whitespace-nowrap"
|
||||
className="h-10 bg-purple-500 hover:bg-purple-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center whitespace-nowrap"
|
||||
>
|
||||
<FileText className="w-4 h-4 mr-2" />
|
||||
업무형태별 집계
|
||||
|
||||
@@ -1,441 +1,17 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { FileText, Search, RefreshCw, Calendar, Edit3, User, Plus } from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { BoardItem } from '@/types';
|
||||
import { BoardList } from './BoardList';
|
||||
import { FileText } from 'lucide-react';
|
||||
|
||||
export function PatchList() {
|
||||
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 [editFormData, setEditFormData] = useState<BoardItem | null>(null);
|
||||
const [userLevel, setUserLevel] = useState(0);
|
||||
const [userId, setUserId] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
loadUserInfo();
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
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: 5, searchKey });
|
||||
const response = await comms.getBoardList(5, searchKey); // bidx=5: 패치내역
|
||||
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 handleRowClick = async (item: BoardItem) => {
|
||||
try {
|
||||
const response = await comms.getBoardDetail(item.idx);
|
||||
if (response.Success && response.Data) {
|
||||
setSelectedItem(response.Data);
|
||||
|
||||
// 개발자(레벨 >= 9) 또는 사번 395552이면 바로 편집 모드
|
||||
if (userLevel >= 9 || userId === '395552') {
|
||||
setEditFormData(response.Data);
|
||||
setShowEditModal(true);
|
||||
} else {
|
||||
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(
|
||||
5, // bidx: 패치내역
|
||||
editFormData.header || '',
|
||||
editFormData.cate || '',
|
||||
editFormData.title || '',
|
||||
editFormData.contents || ''
|
||||
);
|
||||
|
||||
if (response.Success) {
|
||||
alert('등록되었습니다.');
|
||||
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) {
|
||||
alert('수정되었습니다.');
|
||||
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) {
|
||||
alert('삭제되었습니다.');
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
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: 5,
|
||||
gcode: '',
|
||||
header: '',
|
||||
cate: 'PATCH',
|
||||
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">
|
||||
<FileText className="w-5 h-5 mr-2" />
|
||||
패치 내역
|
||||
</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)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{item.cate && (
|
||||
<span className={`px-2 py-0.5 text-xs rounded whitespace-nowrap ${
|
||||
item.cate.toUpperCase() === 'UPDATE'
|
||||
? 'bg-lime-500/20 text-lime-400'
|
||||
: 'bg-red-500/20 text-red-400'
|
||||
}`}>
|
||||
{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>
|
||||
<h4 className="text-white font-medium flex-1 min-w-0 truncate">{item.title}</h4>
|
||||
<div className="flex items-center text-white/60 text-xs flex-shrink-0">
|
||||
<Calendar className="w-3 h-3 mr-1" />
|
||||
{formatDate(item.wdate)}
|
||||
</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 bg-white/10 text-white/70 text-sm rounded">
|
||||
{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-180px)] p-6">
|
||||
<div className="prose prose-invert max-w-none">
|
||||
<div className="text-white whitespace-pre-wrap">{selectedItem.contents}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-white/10 bg-white/5">
|
||||
{(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>
|
||||
)}
|
||||
|
||||
{/* 편집 모달 */}
|
||||
{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" />
|
||||
패치내역 편집
|
||||
</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 || 'PATCH'}
|
||||
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"
|
||||
>
|
||||
<option value="PATCH" className="bg-gray-800">PATCH</option>
|
||||
<option value="UPDATE" className="bg-gray-800">UPDATE</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>
|
||||
)}
|
||||
</div>
|
||||
<BoardList
|
||||
bidx={5}
|
||||
title="패치 내역"
|
||||
icon={<FileText className="w-5 h-5" />}
|
||||
defaultCategory="PATCH"
|
||||
categories={[
|
||||
{ value: 'PATCH', label: 'PATCH', color: 'red' },
|
||||
{ value: 'UPDATE', label: 'UPDATE', color: 'lime' }
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -462,6 +462,8 @@ export interface MachineBridgeInterface {
|
||||
Board_Add(bidx: number, header: string, cate: string, title: string, contents: string): Promise<string>;
|
||||
Board_Edit(idx: number, header: string, cate: string, title: string, contents: string): Promise<string>;
|
||||
Board_Delete(idx: number): Promise<string>;
|
||||
Board_GetReplies(rootIdx: number): Promise<string>;
|
||||
Board_AddReply(rootIdx: number, pidx: number, title: string, contents: string, isComment: boolean): Promise<string>;
|
||||
|
||||
// Mail API (메일 발신 내역)
|
||||
Mail_GetList(startDate: string, endDate: string, searchKey: string): Promise<string>;
|
||||
@@ -849,6 +851,12 @@ export interface BoardItem {
|
||||
close: boolean;
|
||||
remark: string;
|
||||
wuid_name: string;
|
||||
root_idx?: number | null;
|
||||
depth?: number;
|
||||
sort_order?: number;
|
||||
thread_path?: string;
|
||||
is_comment?: boolean;
|
||||
reply_count?: number;
|
||||
}
|
||||
|
||||
// Mail 발신 내역 타입
|
||||
|
||||
Reference in New Issue
Block a user