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

@@ -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>

View File

@@ -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)

View File

@@ -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>(

View File

@@ -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: '메일 내역' },
],
},

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>
);
}

View 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' }
]}
/>
);
}

View File

@@ -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 모달 */}

View File

@@ -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" />

View File

@@ -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' }
]}
/>
);
}

View File

@@ -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 발신 내역 타입