Add Dashboard todo edit/delete/complete features and Note view count tracking

This commit is contained in:
backuppc
2025-12-02 15:03:51 +09:00
parent 6a2485176b
commit e82f86191a
25 changed files with 3512 additions and 324 deletions

View File

@@ -1,7 +1,9 @@
import { useState, useEffect } from 'react';
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 } from '@/pages';
import { Dashboard, Todo, Kuntae, Jobreport, Project, Login, CommonCodePage, ItemsPage, UserListPage, MonthlyWorkPage, MailFormPage, UserAuthPage, Note } from '@/pages';
import { PatchList } from '@/pages/PatchList';
import { MailList } from '@/pages/MailList';
import { comms } from '@/communication';
import { UserInfo } from '@/types';
import { Loader2 } from 'lucide-react';
@@ -92,6 +94,9 @@ export default function App() {
<Route path="/user/auth" element={<UserAuthPage />} />
<Route path="/monthly-work" element={<MonthlyWorkPage />} />
<Route path="/mail-form" element={<MailFormPage />} />
<Route path="/note" element={<Note />} />
<Route path="/patch-list" element={<PatchList />} />
<Route path="/mail-list" element={<MailList />} />
</Route>
</Routes>
{/* Tailwind Breakpoint Indicator - 개발용 */}

View File

@@ -1,3 +1,48 @@
import type {
ApiResponse,
LoginStatusResponse,
CheckAuthResponse,
TodoModel,
PurchaseItem,
PurchaseCount,
JobReportItem,
JobReportUser,
JobReportPermission,
JobReportTypeItem,
JobTypeItem,
MailFormItem,
UserGroupItem,
PermissionInfo,
AuthItem,
AuthFieldInfo,
AuthType,
MyAuthInfo,
ProjectSearchItem,
NoteItem,
KuntaeModel,
HolydayBalance,
HolyUser,
HolyRequestUser,
LoginResult,
UserGroup,
PreviousLoginInfo,
UserInfoDetail,
CommonCodeGroup,
CommonCode,
ItemInfo,
ItemDetail,
SupplierStaff,
PurchaseHistoryItem,
UserLevelInfo,
GroupUser,
UserFullData,
AppVersionInfo,
HolidayItem,
MachineBridgeInterface,
BoardItem,
MailItem,
} from '@/types';
// WebView2 환경 감지
const isWebView = typeof window !== 'undefined' &&
window.chrome?.webview?.hostObjects !== undefined;
@@ -799,7 +844,7 @@ class CommunicationLayer {
public async getJobReportTypeList(sd: string, ed: string, uid: string = ''): Promise<ApiResponse<JobReportTypeItem[]>> {
if (isWebView && machine) {
const result = await machine.Jobreport_GetTypeList(sd, ed, uid);
const result = await machine.Jobreport_GetList(sd, ed, uid, '', '');
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse<JobReportTypeItem[]>>('JOBREPORT_GET_TYPE_LIST', 'JOBREPORT_TYPE_LIST_DATA', { sd, ed, uid });
@@ -1080,6 +1125,159 @@ class CommunicationLayer {
return this.wsRequest<ApiResponse<{ idx: number, name: string, status: string }[]>>('PROJECT_GET_USER_PROJECTS', 'PROJECT_USER_PROJECTS_DATA');
}
}
// ===== Note API (메모장) =====
/**
* 메모장 목록 조회
* @param startDate 시작일 (yyyy-MM-dd)
* @param endDate 종료일 (yyyy-MM-dd)
* @param uid 사용자 ID (빈 문자열이면 전체)
* @returns ApiResponse<NoteItem[]>
*/
public async getNoteList(startDate: string, endDate: string, uid: string = ''): Promise<ApiResponse<NoteItem[]>> {
if (isWebView && machine) {
const result = await machine.Note_GetList(startDate, endDate, uid);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse<NoteItem[]>>('NOTE_GET_LIST', 'NOTE_LIST_DATA', { startDate, endDate, uid });
}
}
/**
* 메모장 상세 조회
* @param idx 메모 인덱스
* @returns ApiResponse<NoteItem>
*/
public async getNoteDetail(idx: number): Promise<ApiResponse<NoteItem>> {
if (isWebView && machine) {
const result = await machine.Note_GetDetail(idx);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse<NoteItem>>('NOTE_GET_DETAIL', 'NOTE_DETAIL_DATA', { idx });
}
}
/**
* 메모장 추가
* @param pdate 날짜 (yyyy-MM-dd)
* @param title 제목
* @param uid 작성자 ID
* @param description 내용 (plain text)
* @param description2 내용 (RTF - not used in web)
* @param share 공유 여부
* @param guid 폴더 GUID (빈 문자열이면 자동 생성)
* @returns ApiResponse with new Idx
*/
public async addNote(
pdate: string,
title: string,
uid: string,
description: string,
description2: string = '',
share: boolean = false,
guid: string = ''
): Promise<ApiResponse<{ Idx: number }>> {
if (isWebView && machine) {
const result = await machine.Note_Add(pdate, title, uid, description, description2, share, guid);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse<{ Idx: number }>>('NOTE_ADD', 'NOTE_ADDED', {
pdate, title, uid, description, description2, share, guid
});
}
}
/**
* 메모장 수정
* @param idx 메모 인덱스
* @param pdate 날짜 (yyyy-MM-dd)
* @param title 제목
* @param uid 작성자 ID
* @param description 내용 (plain text)
* @param description2 내용 (RTF - not used in web)
* @param share 공유 여부
* @param guid 폴더 GUID
* @returns ApiResponse
*/
public async editNote(
idx: number,
pdate: string,
title: string,
uid: string,
description: string,
description2: string = '',
share: boolean = false,
guid: string = ''
): Promise<ApiResponse> {
if (isWebView && machine) {
const result = await machine.Note_Edit(idx, pdate, title, uid, description, description2, share, guid);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse>('NOTE_EDIT', 'NOTE_EDITED', {
idx, pdate, title, uid, description, description2, share, guid
});
}
}
/**
* 메모장 삭제
* @param idx 메모 인덱스
* @returns ApiResponse
*/
public async deleteNote(idx: number): Promise<ApiResponse> {
if (isWebView && machine) {
const result = await machine.Note_Delete(idx);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse>('NOTE_DELETE', 'NOTE_DELETED', { idx });
}
}
/**
* 게시판 목록 조회
* @param bidx 게시판 인덱스 (5=패치내역)
* @param searchKey 검색어
* @returns ApiResponse<BoardItem[]>
*/
public async getBoardList(bidx: number, searchKey: string = ''): Promise<ApiResponse<BoardItem[]>> {
if (isWebView && machine) {
const result = await machine.Board_GetList(bidx, searchKey);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse<BoardItem[]>>('BOARD_GET_LIST', 'BOARD_LIST_DATA', { bidx, searchKey });
}
}
/**
* 게시판 상세 조회
* @param idx 게시판 글 인덱스
* @returns ApiResponse<BoardItem>
*/
public async getBoardDetail(idx: number): Promise<ApiResponse<BoardItem>> {
if (isWebView && machine) {
const result = await machine.Board_GetDetail(idx);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse<BoardItem>>('BOARD_GET_DETAIL', 'BOARD_DETAIL_DATA', { idx });
}
}
/**
* 메일 발신 내역 조회
* @param startDate 시작일 (yyyy-MM-dd)
* @param endDate 종료일 (yyyy-MM-dd)
* @param searchKey 검색어
* @returns ApiResponse<MailItem[]>
*/
public async getMailList(startDate: string, endDate: string, searchKey: string = ''): Promise<ApiResponse<MailItem[]>> {
if (isWebView && machine) {
const result = await machine.Mail_GetList(startDate, endDate, searchKey);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse<MailItem[]>>('MAIL_GET_LIST', 'MAIL_LIST_DATA', { startDate, endDate, searchKey });
}
}
}
export const comms = new CommunicationLayer();

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react';
import { X, ChevronLeft, ChevronRight, Download } from 'lucide-react';
import { comms } from '@/communication';
import { JobReportDayItem, HolidayItem } from '@/types';
import { HolidayItem } from '@/types';
interface JobReportDayDialogProps {
isOpen: boolean;
@@ -32,8 +32,6 @@ export function JobReportDayDialog({ isOpen, onClose, initialMonth }: JobReportD
const [dayColumns, setDayColumns] = useState<DayColumn[]>([]);
const [userRows, setUserRows] = useState<UserRow[]>([]);
const [currentUserId, setCurrentUserId] = useState<string>('');
const [currentUserLevel, setCurrentUserLevel] = useState<number>(0);
const [authLevel, setAuthLevel] = useState<number>(0);
const [canViewAll, setCanViewAll] = useState<boolean>(false);
// 요일 배열
@@ -54,12 +52,10 @@ export function JobReportDayDialog({ isOpen, onClose, initialMonth }: JobReportD
const userId = loginStatus.User.Id;
const userLevel = loginStatus.User.Level || 0;
setCurrentUserId(userId);
setCurrentUserLevel(userLevel);
// 업무일지(jobreport) 권한 가져오기
const authResponse = await comms.checkAuth('jobreport', 5);
const jobReportAuthLevel = authResponse.EffectiveLevel || 0;
setAuthLevel(jobReportAuthLevel);
// 유효 권한 레벨 = Max(사용자레벨, 권한레벨)
const effectiveLevel = Math.max(userLevel, jobReportAuthLevel);

View File

@@ -115,6 +115,15 @@ const dropdownMenus: DropdownMenuConfig[] = [
{ type: 'link', path: '/mail-form', icon: Mail, label: '메일양식' },
],
},
{
label: '문서',
icon: FileText,
items: [
{ type: 'link', path: '/note', icon: FileText, label: '메모장' },
{ type: 'link', path: '/patch-list', icon: FileText, label: '패치 내역' },
{ type: 'link', path: '/mail-list', icon: Mail, label: '메일 내역' },
],
},
];
function DropdownNavMenu({

View File

@@ -0,0 +1,283 @@
import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X, Save, User, Calendar } from 'lucide-react';
import { NoteItem } from '@/types';
import { comms } from '@/communication';
interface NoteEditModalProps {
isOpen: boolean;
editingItem: NoteItem | null;
processing: boolean;
onClose: () => void;
onSave: (formData: {
pdate: string;
title: string;
uid: string;
description: string;
share: boolean;
guid: string;
}) => void;
initialEditMode?: boolean;
}
export function NoteEditModal({
isOpen,
editingItem,
processing,
onClose,
onSave,
initialEditMode = false,
}: NoteEditModalProps) {
const [pdate, setPdate] = useState('');
const [title, setTitle] = useState('');
const [uid, setUid] = useState('');
const [description, setDescription] = useState('');
const [share, setShare] = useState(false);
const [guid, setGuid] = useState('');
const [isEditMode, setIsEditMode] = useState(false);
// 현재 로그인 사용자 정보 로드
const [currentUserId, setCurrentUserId] = useState('');
const [currentUserLevel, setCurrentUserLevel] = useState(0);
useEffect(() => {
const loadUserInfo = async () => {
try {
const loginStatus = await comms.checkLoginStatus();
if (loginStatus.Success && loginStatus.IsLoggedIn && loginStatus.User) {
setCurrentUserId(loginStatus.User.Id);
setCurrentUserLevel(loginStatus.User.Level);
}
} catch (error) {
console.error('로그인 정보 로드 오류:', error);
}
};
loadUserInfo();
}, []);
// 모달이 열릴 때 폼 데이터 초기화
useEffect(() => {
if (isOpen) {
if (editingItem) {
// 기존 메모
setPdate(editingItem.pdate ? editingItem.pdate.split('T')[0] : '');
setTitle(editingItem.title || '');
setUid(editingItem.uid || currentUserId);
setDescription(editingItem.description || '');
setShare(editingItem.share || false);
setGuid(editingItem.guid || '');
setIsEditMode(initialEditMode);
} else {
// 신규 메모 - 편집 모드로 시작
setPdate(new Date().toISOString().split('T')[0]);
setTitle('');
setUid(currentUserId);
setDescription('');
setShare(false);
setGuid('');
setIsEditMode(true);
}
}
}, [isOpen, editingItem, currentUserId, initialEditMode]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({ pdate, title, uid, description, share, guid });
};
if (!isOpen) return null;
// 권한 체크: 자신의 메모이거나 관리자만 수정 가능
const canEdit = !editingItem || editingItem.uid === currentUserId || currentUserLevel >= 5;
const canChangeUser = currentUserLevel >= 5;
const canChangeShare = !editingItem || editingItem.uid === currentUserId;
return createPortal(
<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-5xl 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">
{!editingItem ? '새 메모 작성' : '메모 편집'}
</h2>
<button
onClick={onClose}
className="text-white/50 hover:text-white transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
{/* 본문 - 좌우 레이아웃 */}
<form onSubmit={handleSubmit} className="overflow-y-auto max-h-[calc(90vh-140px)]">
<div className="flex gap-6 p-6">
{/* 좌측 - 정보 영역 */}
<div className="w-64 flex-shrink-0 space-y-4">
{/* 날짜 */}
<div>
<label className="flex items-center text-sm font-medium text-white/70 mb-2">
<Calendar className="w-4 h-4 mr-2" />
{isEditMode && '*'}
</label>
{isEditMode ? (
<input
type="date"
value={pdate}
onChange={(e) => setPdate(e.target.value)}
required
disabled={!canEdit}
className="w-full h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:opacity-50 disabled:cursor-not-allowed"
/>
) : (
<div className="text-white text-sm">{pdate}</div>
)}
</div>
{/* 작성자 */}
<div>
<label className="flex items-center text-sm font-medium text-white/70 mb-2">
<User className="w-4 h-4 mr-2" />
{isEditMode && '*'}
</label>
{isEditMode ? (
<>
<input
type="text"
value={uid}
onChange={(e) => setUid(e.target.value)}
required
disabled={!canEdit || !canChangeUser}
className="w-full h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="사용자 ID"
/>
{!canChangeUser && (
<p className="text-xs text-white/50 mt-1">
</p>
)}
</>
) : (
<div className="text-white text-sm">{uid}</div>
)}
</div>
{/* 공유 여부 */}
<div>
<label className="text-sm font-medium text-white/70 mb-2 block">
</label>
{isEditMode ? (
<>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="note-share"
checked={share}
onChange={(e) => setShare(e.target.checked)}
disabled={!canEdit || !canChangeShare}
className="w-4 h-4 bg-white/20 border border-white/30 rounded focus:ring-2 focus:ring-primary-400 disabled:opacity-50 disabled:cursor-not-allowed"
/>
<label htmlFor="note-share" className="text-sm text-white/70 cursor-pointer">
</label>
</div>
{!canChangeShare && (
<p className="text-xs text-white/50 mt-1">
</p>
)}
</>
) : (
<div className="text-white text-sm">{share ? '공유됨' : '비공유'}</div>
)}
</div>
{!canEdit && isEditMode && (
<div className="bg-red-500/20 border border-red-500/50 rounded-lg p-3 text-red-300 text-xs">
.
</div>
)}
</div>
{/* 우측 - 제목 및 내용 영역 */}
<div className="flex-1 space-y-4">
{/* 제목 */}
<div>
<label className="text-sm font-medium text-white/70 mb-2 block">
{isEditMode && '*'}
</label>
{isEditMode ? (
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
disabled={!canEdit}
className="w-full h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:opacity-50 disabled:cursor-not-allowed"
placeholder="메모 제목을 입력하세요"
/>
) : (
<div className="text-white text-lg font-semibold">{title}</div>
)}
</div>
{/* 내용 */}
<div className="flex-1">
<label className="text-sm font-medium text-white/70 mb-2 block">
</label>
{isEditMode ? (
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
disabled={!canEdit}
rows={18}
className="w-full bg-white/20 border border-white/30 rounded-lg p-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:opacity-50 disabled:cursor-not-allowed resize-none"
placeholder="메모 내용을 입력하세요"
/>
) : (
<div className="bg-white/10 border border-white/20 rounded-lg p-4 text-white whitespace-pre-wrap min-h-[400px]">
{description || '내용이 없습니다.'}
</div>
)}
</div>
</div>
</div>
</form>
{/* 하단 버튼 */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-white/10 bg-white/5">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-lg bg-white/10 hover:bg-white/20 text-white transition-colors"
disabled={processing}
>
</button>
{isEditMode && canEdit && (
<button
type="submit"
onClick={handleSubmit}
disabled={processing}
className="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white transition-colors flex items-center disabled:opacity-50"
>
{processing ? (
<>
<div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"></div>
...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
</>
)}
</button>
)}
</div>
</div>
</div>,
document.body
);
}

View File

@@ -0,0 +1,74 @@
import { createPortal } from 'react-dom';
import { X, Trash2 } from 'lucide-react';
import { NoteItem } from '@/types';
interface NoteViewModalProps {
isOpen: boolean;
note: NoteItem | null;
onClose: () => void;
onEdit: (note: NoteItem) => void;
onDelete?: (note: NoteItem) => void;
}
export function NoteViewModal({ isOpen, note, onClose, onEdit, onDelete }: NoteViewModalProps) {
if (!isOpen || !note) return null;
const handleDelete = () => {
if (window.confirm('정말 삭제하시겠습니까?')) {
onDelete?.(note);
}
};
return createPortal(
<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-2xl max-h-[80vh] 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">{note.title || '제목 없음'}</h2>
<button
onClick={onClose}
className="text-white/50 hover:text-white transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
{/* 본문 - 메모 내용만 표시 */}
<div className="overflow-y-auto max-h-[calc(80vh-140px)] p-6">
<div className="bg-white/10 border border-white/20 rounded-lg p-4 text-white whitespace-pre-wrap min-h-[300px]">
{note.description || '내용이 없습니다.'}
</div>
</div>
{/* 하단 버튼 */}
<div className="flex items-center justify-between px-6 py-4 border-t border-white/10 bg-white/5">
<button
onClick={handleDelete}
className="px-4 py-2 rounded-lg bg-danger-500 hover:bg-danger-600 text-white transition-colors flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
</button>
<div className="flex items-center gap-3">
<button
onClick={onClose}
className="px-4 py-2 rounded-lg bg-white/10 hover:bg-white/20 text-white transition-colors"
>
</button>
<button
onClick={() => {
onClose();
onEdit(note);
}}
className="px-4 py-2 bg-success-500 hover:bg-success-600 text-white rounded-lg transition-colors"
>
</button>
</div>
</div>
</div>
</div>,
document.body
);
}

View File

@@ -9,9 +9,20 @@ import {
RefreshCw,
ClipboardList,
Clock,
FileText,
Share2,
Lock,
List,
Plus,
X,
Loader2,
Edit2,
Trash2,
} from 'lucide-react';
import { comms } from '@/communication';
import { TodoModel, PurchaseItem } from '@/types';
import { TodoModel, TodoStatus, TodoPriority, PurchaseItem, NoteItem, JobReportItem } from '@/types';
import { NoteViewModal } from '@/components/note/NoteViewModal';
import { NoteEditModal } from '@/components/note/NoteEditModal';
interface StatCardProps {
title: string;
@@ -55,10 +66,31 @@ export function Dashboard() {
const [urgentTodos, setUrgentTodos] = useState<TodoModel[]>([]);
const [purchaseNRList, setPurchaseNRList] = useState<PurchaseItem[]>([]);
const [purchaseCRList, setPurchaseCRList] = useState<PurchaseItem[]>([]);
const [recentNotes, setRecentNotes] = useState<NoteItem[]>([]);
// 모달 상태
const [showNRModal, setShowNRModal] = useState(false);
const [showCRModal, setShowCRModal] = useState(false);
const [showNoteModal, setShowNoteModal] = useState(false);
const [showNoteEditModal, setShowNoteEditModal] = useState(false);
const [showNoteAddModal, setShowNoteAddModal] = useState(false);
const [selectedNote, setSelectedNote] = useState<NoteItem | null>(null);
const [editingNote, setEditingNote] = useState<NoteItem | null>(null);
const [processing, setProcessing] = useState(false);
// 할일 추가 모달 상태
const [showTodoAddModal, setShowTodoAddModal] = useState(false);
const [showTodoEditModal, setShowTodoEditModal] = useState(false);
const [editingTodo, setEditingTodo] = useState<TodoModel | null>(null);
const [todoFormData, setTodoFormData] = useState({
title: '',
remark: '',
expire: '',
seqno: 0 as TodoPriority,
flag: false,
request: '',
status: '0' as TodoStatus,
});
const loadDashboardData = useCallback(async () => {
try {
@@ -83,11 +115,13 @@ export function Dashboard() {
urgentTodosResponse,
allTodosResponse,
jobreportResponse,
notesResponse,
] = await Promise.all([
comms.getPurchaseWaitCount(),
comms.getUrgentTodos(),
comms.getTodos(),
comms.getJobReportList(todayStr, todayStr, currentUserId, ''),
comms.getNoteList('2000-01-01', todayStr, ''),
]);
setPurchaseNR(purchaseCount.NR);
@@ -99,17 +133,22 @@ export function Dashboard() {
if (allTodosResponse.Success && allTodosResponse.Data) {
// 진행, 대기 상태의 할일만 카운트 (보류, 취소 제외)
const pendingCount = allTodosResponse.Data.filter(t => t.status === '0' || t.status === '1').length;
const pendingCount = allTodosResponse.Data.filter((t: TodoModel) => t.status === '0' || t.status === '1').length;
setTodoCount(pendingCount);
}
// 오늘 업무일지 작성시간 계산
if (jobreportResponse.Success && jobreportResponse.Data) {
const totalHrs = jobreportResponse.Data.reduce((acc, item) => acc + (item.hrs || 0), 0);
const totalHrs = jobreportResponse.Data.reduce((acc: number, item: JobReportItem) => acc + (item.hrs || 0), 0);
setTodayWorkHrs(totalHrs);
} else {
setTodayWorkHrs(0);
}
// 최근 메모 목록 (최대 10개)
if (notesResponse.Success && notesResponse.Data) {
setRecentNotes(notesResponse.Data.slice(0, 10));
}
} catch (error) {
console.error('대시보드 데이터 로드 오류:', error);
} finally {
@@ -199,23 +238,263 @@ export function Dashboard() {
);
}
return (
<div className="space-y-6 animate-fade-in">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-white"> </h2>
<button
onClick={handleRefresh}
disabled={refreshing}
className="flex items-center space-x-2 px-4 py-2 glass-effect rounded-lg text-white/70 hover:text-white transition-colors"
>
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
<span></span>
</button>
</div>
const handleNoteClick = async (note: NoteItem) => {
try {
const response = await comms.getNoteDetail(note.idx);
if (response.Success && response.Data) {
setSelectedNote(response.Data);
setShowNoteModal(true);
}
} catch (error) {
console.error('메모 조회 오류:', error);
}
};
{/* 통계 카드 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
const handleNoteAdd = () => {
setEditingNote(null);
setShowNoteAddModal(true);
};
const handleNoteDelete = async (note: NoteItem) => {
setProcessing(true);
try {
const response = await comms.deleteNote(note.idx);
if (response.Success) {
setShowNoteModal(false);
loadDashboardData();
} else {
alert(response.Message || '삭제에 실패했습니다.');
}
} catch (error) {
console.error('삭제 오류:', error);
alert('서버 연결에 실패했습니다: ' + (error instanceof Error ? error.message : String(error)));
} finally {
setProcessing(false);
}
};
const handleTodoAdd = () => {
setTodoFormData({
title: '',
remark: '',
expire: '',
seqno: 0,
flag: false,
request: '',
status: '0',
});
setShowTodoAddModal(true);
};
const handleTodoEdit = (todo: TodoModel) => {
setEditingTodo(todo);
setTodoFormData({
title: todo.title || '',
remark: todo.remark,
expire: todo.expire || '',
seqno: todo.seqno as TodoPriority,
flag: todo.flag,
request: todo.request || '',
status: todo.status as TodoStatus,
});
setShowTodoEditModal(true);
};
const handleTodoSave = async () => {
if (!todoFormData.remark.trim()) {
alert('할일 내용을 입력해주세요.');
return;
}
setProcessing(true);
try {
const response = await comms.createTodo(
todoFormData.title,
todoFormData.remark,
todoFormData.expire || null,
todoFormData.seqno,
todoFormData.flag,
todoFormData.request || null,
todoFormData.status
);
if (response.Success) {
setShowTodoAddModal(false);
loadDashboardData();
} else {
alert(response.Message || '할일 추가에 실패했습니다.');
}
} catch (error) {
console.error('할일 추가 오류:', error);
alert('서버 연결에 실패했습니다: ' + (error instanceof Error ? error.message : String(error)));
} finally {
setProcessing(false);
}
};
const handleTodoUpdate = async () => {
if (!editingTodo || !todoFormData.remark.trim()) {
alert('할일 내용을 입력해주세요.');
return;
}
setProcessing(true);
try {
const response = await comms.updateTodo(
editingTodo.idx,
todoFormData.title,
todoFormData.remark,
todoFormData.expire || null,
todoFormData.seqno,
todoFormData.flag,
todoFormData.request || null,
todoFormData.status
);
if (response.Success) {
setShowTodoEditModal(false);
setEditingTodo(null);
loadDashboardData();
} else {
alert(response.Message || '할일 수정에 실패했습니다.');
}
} catch (error) {
console.error('할일 수정 오류:', error);
alert('서버 연결에 실패했습니다: ' + (error instanceof Error ? error.message : String(error)));
} finally {
setProcessing(false);
}
};
const handleTodoDelete = async () => {
if (!editingTodo) return;
if (!confirm('정말로 이 할일을 삭제하시겠습니까?')) {
return;
}
setProcessing(true);
try {
const response = await comms.deleteTodo(editingTodo.idx);
if (response.Success) {
setShowTodoEditModal(false);
setEditingTodo(null);
loadDashboardData();
} else {
alert(response.Message || '할일 삭제에 실패했습니다.');
}
} catch (error) {
console.error('할일 삭제 오류:', error);
alert('서버 연결에 실패했습니다.');
} finally {
setProcessing(false);
}
};
const handleTodoComplete = async () => {
if (!editingTodo) return;
setProcessing(true);
try {
const response = await comms.updateTodo(
editingTodo.idx,
editingTodo.title,
editingTodo.remark,
editingTodo.expire,
editingTodo.seqno,
editingTodo.flag,
editingTodo.request,
'5' // 완료 상태
);
if (response.Success) {
setShowTodoEditModal(false);
setEditingTodo(null);
loadDashboardData();
} else {
alert(response.Message || '할일 완료 처리에 실패했습니다.');
}
} catch (error) {
console.error('할일 완료 오류:', error);
alert('서버 연결에 실패했습니다.');
} finally {
setProcessing(false);
}
};
const handleNoteSave = async (formData: {
pdate: string;
title: string;
uid: string;
description: string;
share: boolean;
guid: string;
}) => {
if (!formData.pdate) {
alert('날짜를 입력해주세요.');
return;
}
if (!formData.title.trim()) {
alert('제목을 입력해주세요.');
return;
}
setProcessing(true);
try {
const response = editingNote
? await comms.editNote(
editingNote.idx,
formData.pdate,
formData.title,
formData.uid,
formData.description,
'',
formData.share,
formData.guid
)
: await comms.addNote(
formData.pdate,
formData.title,
formData.uid,
formData.description,
'',
formData.share,
formData.guid
);
if (response.Success) {
setShowNoteEditModal(false);
setShowNoteAddModal(false);
loadDashboardData();
} else {
alert(response.Message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('저장 오류:', error);
alert('서버 연결에 실패했습니다: ' + (error instanceof Error ? error.message : String(error)));
} finally {
setProcessing(false);
}
};
return (
<div className="flex gap-6 animate-fade-in">
{/* 메인 컨텐츠 */}
<div className="flex-1 space-y-6">
{/* 헤더 */}
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold text-white"> </h2>
<button
onClick={handleRefresh}
disabled={refreshing}
className="flex items-center space-x-2 px-4 py-2 glass-effect rounded-lg text-white/70 hover:text-white transition-colors"
>
<RefreshCw className={`w-4 h-4 ${refreshing ? 'animate-spin' : ''}`} />
<span></span>
</button>
</div>
{/* 통계 카드 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<StatCard
title="구매요청 (NR)"
value={purchaseNR}
@@ -244,21 +523,31 @@ export function Dashboard() {
color="text-cyan-400"
onClick={() => navigate('/jobreport')}
/>
</div>
</div>
{/* 급한 할일 목록 */}
<div className="glass-effect rounded-2xl overflow-hidden">
{/* 할일 목록 */}
<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">
<AlertTriangle className="w-5 h-5 mr-2 text-warning-400" />
</h3>
<button
onClick={() => navigate('/todo')}
className="text-sm text-primary-400 hover:text-primary-300 transition-colors"
>
</button>
<div className="flex items-center gap-2">
<button
onClick={handleTodoAdd}
className="p-1.5 rounded-lg bg-primary-500/20 text-primary-400 hover:bg-primary-500/30 transition-colors"
title="할일 추가"
>
<Plus className="w-4 h-4" />
</button>
<button
onClick={() => navigate('/todo')}
className="p-1.5 rounded-lg bg-white/10 text-white/70 hover:bg-white/20 transition-colors"
title="전체보기"
>
<List className="w-4 h-4" />
</button>
</div>
</div>
<div className="divide-y divide-white/10">
@@ -267,7 +556,7 @@ export function Dashboard() {
<div
key={todo.idx}
className="px-6 py-4 hover:bg-white/5 transition-colors cursor-pointer"
onClick={() => navigate('/todo')}
onClick={() => handleTodoEdit(todo)}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
@@ -302,25 +591,436 @@ export function Dashboard() {
) : (
<div className="px-6 py-8 text-center text-white/50">
<CheckCircle className="w-12 h-12 mx-auto mb-3 text-success-400/50" />
<p> </p>
<p> </p>
</div>
)}
</div>
</div>
{/* NR 모달 */}
{showNRModal && (
<Modal title="구매요청 (NR) 목록" onClose={() => setShowNRModal(false)}>
<PurchaseTable data={purchaseNRList} />
</Modal>
)}
{/* CR 모달 */}
{showCRModal && (
<Modal title="구매요청 (CR) 목록" onClose={() => setShowCRModal(false)}>
<PurchaseTable data={purchaseCRList} />
</Modal>
)}
{/* 메모 보기 모달 */}
<NoteViewModal
isOpen={showNoteModal}
note={selectedNote}
onClose={() => setShowNoteModal(false)}
onEdit={(note) => {
setShowNoteModal(false);
setEditingNote(note);
setShowNoteEditModal(true);
}}
onDelete={handleNoteDelete}
/>
{/* 메모 편집 모달 */}
<NoteEditModal
isOpen={showNoteEditModal}
editingItem={editingNote}
processing={processing}
onClose={() => setShowNoteEditModal(false)}
onSave={handleNoteSave}
initialEditMode={true}
/>
{/* 메모 추가 모달 */}
<NoteEditModal
isOpen={showNoteAddModal}
editingItem={null}
processing={processing}
onClose={() => setShowNoteAddModal(false)}
onSave={handleNoteSave}
initialEditMode={true}
/>
{/* 할일 추가 모달 */}
{showTodoAddModal && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50" onClick={() => setShowTodoAddModal(false)}>
<div className="flex items-center justify-center min-h-screen p-4">
<div
className="glass-effect rounded-2xl w-full max-w-2xl animate-slide-up"
onClick={(e) => e.stopPropagation()}
>
{/* 헤더 */}
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
<h2 className="text-xl font-semibold text-white flex items-center">
<Plus className="w-5 h-5 mr-2" />
</h2>
<button onClick={() => setShowTodoAddModal(false)} className="text-white/70 hover:text-white transition-colors">
<X className="w-6 h-6" />
</button>
</div>
{/* 내용 */}
<div className="p-6 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-white/70 text-sm font-medium mb-2"> ()</label>
<input
type="text"
value={todoFormData.title}
onChange={(e) => setTodoFormData(prev => ({ ...prev, title: e.target.value }))}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
placeholder="할일 제목을 입력하세요"
/>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2"> ()</label>
<input
type="date"
value={todoFormData.expire}
onChange={(e) => setTodoFormData(prev => ({ ...prev, expire: e.target.value }))}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
/>
</div>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2"> *</label>
<textarea
value={todoFormData.remark}
onChange={(e) => setTodoFormData(prev => ({ ...prev, remark: e.target.value }))}
rows={3}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
placeholder="할일 내용을 입력하세요 (필수)"
required
/>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2"></label>
<input
type="text"
value={todoFormData.request}
onChange={(e) => setTodoFormData(prev => ({ ...prev, request: e.target.value }))}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
placeholder="업무 요청자를 입력하세요"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-white/70 text-sm font-medium mb-2"></label>
<div className="flex flex-wrap gap-2">
{[
{ value: '0', label: '대기' },
{ value: '1', label: '진행' },
{ value: '3', label: '보류' },
{ value: '2', label: '취소' },
{ value: '5', label: '완료' },
].map((option) => (
<button
key={option.value}
type="button"
onClick={() => setTodoFormData(prev => ({ ...prev, status: option.value as TodoStatus }))}
className={`px-3 py-1 rounded-lg text-xs font-medium border transition-all ${
todoFormData.status === option.value
? getStatusClass(option.value)
: 'bg-white/10 text-white/50 border-white/20 hover:bg-white/20'
}`}
>
{option.label}
</button>
))}
</div>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2"></label>
<select
value={todoFormData.seqno}
onChange={(e) => setTodoFormData(prev => ({ ...prev, seqno: parseInt(e.target.value) as TodoPriority }))}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
>
<option value={0}></option>
<option value={1}></option>
<option value={2}> </option>
<option value={3}></option>
</select>
</div>
<div className="flex items-end">
<label className="flex items-center text-white/70 text-sm font-medium cursor-pointer">
<input
type="checkbox"
checked={todoFormData.flag}
onChange={(e) => setTodoFormData(prev => ({ ...prev, flag: e.target.checked }))}
className="mr-2 text-primary-500 focus:ring-primary-400 focus:ring-offset-0 rounded"
/>
( )
</label>
</div>
</div>
</div>
{/* 푸터 */}
<div className="px-6 py-4 border-t border-white/10 flex justify-end space-x-3">
<button
type="button"
onClick={() => setShowTodoAddModal(false)}
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors"
>
</button>
<button
type="button"
onClick={handleTodoSave}
disabled={processing}
className="bg-primary-500 hover:bg-primary-600 text-white px-6 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50"
>
{processing ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Plus className="w-4 h-4 mr-2" />
)}
</button>
</div>
</div>
</div>
</div>
)}
{/* 할일 수정 모달 */}
{showTodoEditModal && editingTodo && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50" onClick={() => setShowTodoEditModal(false)}>
<div className="flex items-center justify-center min-h-screen p-4">
<div
className="glass-effect rounded-2xl w-full max-w-2xl animate-slide-up"
onClick={(e) => e.stopPropagation()}
>
{/* 헤더 */}
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
<h2 className="text-xl font-semibold text-white flex items-center">
<Edit2 className="w-5 h-5 mr-2" />
</h2>
<button onClick={() => setShowTodoEditModal(false)} className="text-white/70 hover:text-white transition-colors">
<X className="w-6 h-6" />
</button>
</div>
{/* 내용 */}
<div className="p-6 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-white/70 text-sm font-medium mb-2"> ()</label>
<input
type="text"
value={todoFormData.title}
onChange={(e) => setTodoFormData(prev => ({ ...prev, title: e.target.value }))}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
placeholder="할일 제목을 입력하세요"
/>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2"> ()</label>
<input
type="date"
value={todoFormData.expire}
onChange={(e) => setTodoFormData(prev => ({ ...prev, expire: e.target.value }))}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
/>
</div>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2"> *</label>
<textarea
value={todoFormData.remark}
onChange={(e) => setTodoFormData(prev => ({ ...prev, remark: e.target.value }))}
rows={3}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
placeholder="할일 내용을 입력하세요 (필수)"
required
/>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2"></label>
<input
type="text"
value={todoFormData.request}
onChange={(e) => setTodoFormData(prev => ({ ...prev, request: e.target.value }))}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
placeholder="업무 요청자를 입력하세요"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-white/70 text-sm font-medium mb-2"></label>
<div className="flex flex-wrap gap-2">
{[
{ value: '0', label: '대기' },
{ value: '1', label: '진행' },
{ value: '3', label: '보류' },
{ value: '2', label: '취소' },
{ value: '5', label: '완료' },
].map((option) => (
<button
key={option.value}
type="button"
onClick={() => setTodoFormData(prev => ({ ...prev, status: option.value as TodoStatus }))}
className={`px-3 py-1 rounded-lg text-xs font-medium border transition-all ${
todoFormData.status === option.value
? getStatusClass(option.value)
: 'bg-white/10 text-white/50 border-white/20 hover:bg-white/20'
}`}
>
{option.label}
</button>
))}
</div>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2"></label>
<select
value={todoFormData.seqno}
onChange={(e) => setTodoFormData(prev => ({ ...prev, seqno: parseInt(e.target.value) as TodoPriority }))}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
>
<option value={0}></option>
<option value={1}></option>
<option value={2}> </option>
<option value={3}></option>
</select>
</div>
<div className="flex items-end">
<label className="flex items-center text-white/70 text-sm font-medium cursor-pointer">
<input
type="checkbox"
checked={todoFormData.flag}
onChange={(e) => setTodoFormData(prev => ({ ...prev, flag: e.target.checked }))}
className="mr-2 text-primary-500 focus:ring-primary-400 focus:ring-offset-0 rounded"
/>
( )
</label>
</div>
</div>
</div>
{/* 푸터 */}
<div className="px-6 py-4 border-t border-white/10 flex justify-between">
{/* 왼쪽: 삭제 버튼 */}
<div>
<button
type="button"
onClick={handleTodoDelete}
disabled={processing}
className="bg-danger-500 hover:bg-danger-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50"
>
<Trash2 className="w-4 h-4 mr-2" />
</button>
</div>
{/* 오른쪽: 취소, 완료, 수정 버튼 */}
<div className="flex space-x-3">
<button
type="button"
onClick={() => setShowTodoEditModal(false)}
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors"
>
</button>
{editingTodo.status !== '5' && (
<button
type="button"
onClick={handleTodoComplete}
disabled={processing}
className="bg-success-500 hover:bg-success-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50"
>
<CheckCircle className="w-4 h-4 mr-2" />
</button>
)}
<button
type="button"
onClick={handleTodoUpdate}
disabled={processing}
className="bg-primary-500 hover:bg-primary-600 text-white px-6 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50"
>
{processing ? (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Edit2 className="w-4 h-4 mr-2" />
)}
</button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
{/* NR 모달 */}
{showNRModal && (
<Modal title="구매요청 (NR) 목록" onClose={() => setShowNRModal(false)}>
<PurchaseTable data={purchaseNRList} />
</Modal>
)}
{/* 우측 사이드바 - 메모 리스트 */}
<div className="w-60 space-y-4">
<div className="glass-effect rounded-2xl overflow-hidden">
<div className="px-4 py-3 border-b border-white/10 flex items-center justify-between">
<h3 className="text-base font-semibold text-white flex items-center">
<FileText className="w-4 h-4 mr-2" />
</h3>
<div className="flex items-center gap-2">
<button
onClick={handleNoteAdd}
className="p-1.5 rounded-lg bg-primary-500/20 text-primary-400 hover:bg-primary-500/30 transition-colors"
title="메모 추가"
>
<Plus className="w-4 h-4" />
</button>
<button
onClick={() => navigate('/note')}
className="p-1.5 rounded-lg bg-white/10 text-white/70 hover:bg-white/20 transition-colors"
title="전체보기"
>
<List className="w-4 h-4" />
</button>
</div>
</div>
{/* CR 모달 */}
{showCRModal && (
<Modal title="구매요청 (CR) 목록" onClose={() => setShowCRModal(false)}>
<PurchaseTable data={purchaseCRList} />
</Modal>
)}
<div className="divide-y divide-white/10 max-h-[calc(100vh-200px)] overflow-y-auto">
{recentNotes.length > 0 ? (
recentNotes.map((note) => (
<div
key={note.idx}
className="px-4 py-2.5 hover:bg-white/5 transition-colors cursor-pointer group"
onClick={() => handleNoteClick(note)}
>
<div className="flex items-center gap-2">
{note.share ? (
<Share2 className="w-3 h-3 text-green-400 flex-shrink-0" />
) : (
<Lock className="w-3 h-3 text-blue-400 flex-shrink-0" />
)}
<p className="text-white text-sm truncate flex-1">
{(note.title || '제목 없음').length > 15 ? `${(note.title || '제목 없음').substring(0, 15)}...` : (note.title || '제목 없음')}
</p>
</div>
</div>
))
) : (
<div className="px-4 py-8 text-center text-white/50">
<FileText className="w-10 h-10 mx-auto mb-2 text-white/30" />
<p className="text-sm"> </p>
</div>
)}
</div>
</div>
</div>
</div>
);
}

View File

@@ -117,12 +117,12 @@ export function Jobreport() {
initialize();
}, []);
// 초기화 완료 후 조회 실행
// 초기화 완료 후 조회 실행 (최초 1회만)
useEffect(() => {
if (initialized && startDate && endDate && selectedUser) {
handleSearchAndLoadToday();
}
}, [initialized, startDate, endDate, selectedUser]);
}, [initialized]); // startDate, endDate, selectedUser 의존성 제거 (날짜 변경 시 자동 조회 방지)
// 검색 + 오늘 근무시간 로드 (순차 실행)
const handleSearchAndLoadToday = async () => {
@@ -373,6 +373,34 @@ export function Jobreport() {
handleSearch();
};
// 빠른 날짜 선택 함수들
const setToday = () => {
const today = new Date();
setStartDate(formatDateLocal(today));
};
const setThisMonth = () => {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
setStartDate(formatDateLocal(startOfMonth));
setEndDate(formatDateLocal(endOfMonth));
};
const setYesterday = () => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
setStartDate(formatDateLocal(yesterday));
};
const setLastMonth = () => {
const now = new Date();
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0);
setStartDate(formatDateLocal(lastMonthStart));
setEndDate(formatDateLocal(lastMonthEnd));
};
return (
<div className="space-y-6 animate-fade-in">
{/* 검색 필터 */}
@@ -381,6 +409,38 @@ export function Jobreport() {
{/* 좌측: 필터 영역 */}
<div className="flex-1">
<div className="flex items-start gap-3">
{/* 빠른 날짜 선택 버튼 */}
<div className="flex flex-col 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"
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"
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"
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"
title="저번달 1일부터 말일까지"
>
</button>
</div>
{/* 필터 입력 영역: 2행 2열 */}
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
{/* 1행: 시작일, 담당자 */}

View File

@@ -3,7 +3,6 @@ import {
Calendar,
Search,
Trash2,
AlertTriangle,
Clock,
CheckCircle,
XCircle,
@@ -23,7 +22,7 @@ import { KuntaeEditModal, KuntaeFormData } from '@/components/kuntae/KuntaeEditM
export function Kuntae() {
const [kuntaeList, setKuntaeList] = useState<KuntaeModel[]>([]);
const [loading, setLoading] = useState(false);
const [processing, setProcessing] = useState(false);
const [_processing, setProcessing] = useState(false);
// 검색 조건
const [startDate, setStartDate] = useState('');
@@ -441,7 +440,7 @@ export function Kuntae() {
{item.contents || '-'}
</td>
<td className="px-4 py-3 text-white text-sm">
{item.UserName || item.uname || item.uid}
{item.UserName || item.uid}
</td>
<td className="px-4 py-3 text-white/50 text-xs">
{item.extcate ? `${item.extcate}` : '-'}

View File

@@ -0,0 +1,288 @@
import { useState, useEffect } from 'react';
import { Mail, Search, RefreshCw, Calendar } from 'lucide-react';
import { comms } from '@/communication';
import { MailItem, UserInfo } from '@/types';
export function MailList() {
const [mailList, setMailList] = useState<MailItem[]>([]);
const [loading, setLoading] = useState(false);
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [searchKey, setSearchKey] = useState('');
const [selectedItem, setSelectedItem] = useState<MailItem | null>(null);
const [showModal, setShowModal] = useState(false);
const [currentUser, setCurrentUser] = useState<UserInfo | null>(null);
const formatDateLocal = (date: Date) => {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
};
useEffect(() => {
// 로그인 정보 로드
const loadLoginInfo = async () => {
try {
const response = await comms.checkLoginStatus();
if (response.Success && response.IsLoggedIn && response.User) {
setCurrentUser(response.User);
}
} catch (error) {
console.error('로그인 정보 로드 오류:', error);
}
};
loadLoginInfo();
const now = new Date();
const tenDaysAgo = new Date();
tenDaysAgo.setDate(now.getDate() - 10);
const start = formatDateLocal(tenDaysAgo);
const end = formatDateLocal(now);
setStartDate(start);
setEndDate(end);
// 날짜 설정 후 바로 데이터 로드
setTimeout(() => {
loadDataWithDates(start, end);
}, 100);
}, []);
const loadDataWithDates = async (start: string, end: string) => {
setLoading(true);
try {
console.log('메일내역 조회:', { start, end, searchKey });
const response = await comms.getMailList(start, end, searchKey);
console.log('메일내역 응답:', response);
if (response.Success && response.Data) {
setMailList(response.Data);
} else {
console.warn('메일내역 없음:', response.Message);
setMailList([]);
}
} catch (error) {
console.error('메일내역 로드 오류:', error);
alert('데이터를 불러오는 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
};
const loadData = async () => {
if (!startDate || !endDate) return;
await loadDataWithDates(startDate, endDate);
};
const handleSearch = () => {
if (new Date(startDate) > new Date(endDate)) {
alert('시작일은 종료일보다 늦을 수 없습니다.');
return;
}
loadData();
};
const handleRowClick = (item: MailItem) => {
// 레벨 9 이상(개발자)만 상세보기 가능
if (!currentUser || currentUser.Level < 9) {
return;
}
setSelectedItem(item);
setShowModal(true);
};
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');
const hh = String(date.getHours()).padStart(2, '0');
const mi = String(date.getMinutes()).padStart(2, '0');
return `${yy}.${mm}.${dd} ${hh}:${mi}`;
} 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">
<label className="text-white/70 text-sm font-medium whitespace-nowrap"></label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-36 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
<span className="text-white/70">~</span>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-36 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
</div>
<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>
</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">
<Mail className="w-5 h-5 mr-2" />
</h3>
<span className="text-white/60 text-sm">{mailList.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>
) : mailList.length === 0 ? (
<div className="px-6 py-8 text-center">
<Mail className="w-12 h-12 mx-auto mb-3 text-white/30" />
<p className="text-white/50"> .</p>
</div>
) : (
mailList.map((item) => (
<div
key={item.idx}
className={`px-6 py-4 transition-colors ${currentUser && currentUser.Level >= 9 ? 'hover:bg-white/5 cursor-pointer' : 'cursor-default'}`}
onClick={() => handleRowClick(item)}
>
<div className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{item.cate && (
<span className="px-2 py-0.5 bg-primary-500/20 text-primary-400 text-xs rounded">
{item.cate}
</span>
)}
{item.project && (
<span className="px-2 py-0.5 bg-white/10 text-white/70 text-xs rounded">
{item.project}
</span>
)}
</div>
<h4 className="text-white font-medium mb-1">{item.subject}</h4>
<div className="flex items-center gap-4 text-white/60 text-sm">
<div>: {item.fromlist}</div>
<div>: {item.tolist}</div>
</div>
</div>
<div className="flex flex-col items-end gap-1 flex-shrink-0">
<div className="flex items-center text-white/60 text-xs">
<Calendar className="w-3 h-3 mr-1" />
{formatDate(item.wdate)}
</div>
</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.cate && (
<span className="px-2 py-1 bg-primary-500/20 text-primary-400 text-sm rounded">
{selectedItem.cate}
</span>
)}
<h2 className="text-xl font-bold text-white ml-2">{selectedItem.subject}</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 space-y-2 text-sm">
<div className="flex items-start gap-2 text-white/70">
<span className="font-medium w-16">:</span>
<span className="text-white">{selectedItem.fromlist}</span>
</div>
<div className="flex items-start gap-2 text-white/70">
<span className="font-medium w-16">:</span>
<span className="text-white">{selectedItem.tolist}</span>
</div>
{selectedItem.cclist && (
<div className="flex items-start gap-2 text-white/70">
<span className="font-medium w-16">:</span>
<span className="text-white">{selectedItem.cclist}</span>
</div>
)}
{selectedItem.bcclist && (
<div className="flex items-start gap-2 text-white/70">
<span className="font-medium w-16">:</span>
<span className="text-white">{selectedItem.bcclist}</span>
</div>
)}
<div className="flex items-center gap-2 text-white/60">
<Calendar className="w-4 h-4" />
{formatDate(selectedItem.wdate)}
</div>
</div>
<div className="overflow-y-auto max-h-[calc(90vh-280px)] p-6">
<div
className="prose prose-invert max-w-none"
dangerouslySetInnerHTML={{ __html: selectedItem.htmlbody }}
/>
</div>
<div className="flex items-center justify-end px-6 py-4 border-t border-white/10 bg-white/5">
<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>
);
}

View File

@@ -0,0 +1,452 @@
import { useState, useEffect, useCallback } from 'react';
import {
FileText,
Search,
RefreshCw,
Plus,
Edit,
Trash2,
Share2,
Lock,
} from 'lucide-react';
import { comms } from '@/communication';
import { NoteItem } from '@/types';
import { NoteEditModal } from '@/components/note/NoteEditModal';
import { NoteViewModal } from '@/components/note/NoteViewModal';
export function Note() {
const [noteList, setNoteList] = useState<NoteItem[]>([]);
const [loading, setLoading] = useState(false);
const [processing, setProcessing] = useState(false);
// 검색 조건
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [searchKey, setSearchKey] = useState('');
// 모달 상태
const [showModal, setShowModal] = useState(false);
const [showViewModal, setShowViewModal] = useState(false);
const [editingItem, setEditingItem] = useState<NoteItem | null>(null);
const [selectedNote, setSelectedNote] = useState<NoteItem | null>(null);
// 페이징 상태
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 10;
// 날짜 포맷 헬퍼 함수 (로컬 시간 기준)
const formatDateLocal = (date: Date) => {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
};
// 초기화 완료 플래그
const [initialized, setInitialized] = useState(false);
// 날짜 초기화
useEffect(() => {
const now = new Date();
// 2000년부터 현재까지 데이터 조회
const startOfPeriod = new Date(2000, 0, 1);
const sd = formatDateLocal(startOfPeriod);
const ed = formatDateLocal(now);
setStartDate(sd);
setEndDate(ed);
// 초기화 완료 표시
setInitialized(true);
}, []);
// 초기화 완료 후 조회 실행 (최초 1회만)
useEffect(() => {
if (initialized && startDate && endDate) {
handleSearch();
}
}, [initialized]);
// 데이터 로드
const loadData = useCallback(async () => {
if (!startDate || !endDate) return;
setLoading(true);
try {
console.log('메모장 조회 요청:', { startDate, endDate });
const response = await comms.getNoteList(startDate, endDate, '');
console.log('메모장 조회 응답:', response);
if (response.Success && response.Data) {
console.log('메모장 데이터 개수:', response.Data.length);
setNoteList(response.Data);
} else {
console.log('메모장 조회 실패 또는 데이터 없음:', response);
setNoteList([]);
}
} catch (error) {
console.error('메모장 목록 로드 오류:', error);
alert('데이터를 불러오는 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
}, [startDate, endDate]);
// 검색
const handleSearch = async () => {
if (new Date(startDate) > new Date(endDate)) {
alert('시작일은 종료일보다 늦을 수 없습니다.');
return;
}
await loadData();
};
// 새 메모 추가 모달
const openAddModal = () => {
setEditingItem(null);
setShowModal(true);
};
// 메모 클릭 (보기 모달)
const handleNoteClick = async (item: NoteItem) => {
try {
const response = await comms.getNoteDetail(item.idx);
if (response.Success && response.Data) {
setSelectedNote(response.Data);
setShowViewModal(true);
}
} catch (error) {
console.error('메모 조회 오류:', error);
alert('데이터를 불러오는 중 오류가 발생했습니다.');
}
};
// 편집 모달
const openEditModal = async (item: NoteItem) => {
try {
const response = await comms.getNoteDetail(item.idx);
if (response.Success && response.Data) {
setEditingItem(response.Data);
setShowModal(true);
}
} catch (error) {
console.error('메모 조회 오류:', error);
alert('데이터를 불러오는 중 오류가 발생했습니다.');
}
};
// 저장
const handleSave = async (formData: {
pdate: string;
title: string;
uid: string;
description: string;
share: boolean;
guid: string;
}) => {
if (!formData.pdate) {
alert('날짜를 입력해주세요.');
return;
}
if (!formData.title.trim()) {
alert('제목을 입력해주세요.');
return;
}
setProcessing(true);
try {
let response;
if (editingItem) {
response = await comms.editNote(
editingItem.idx,
formData.pdate,
formData.title,
formData.uid,
formData.description,
'',
formData.share,
formData.guid
);
} else {
response = await comms.addNote(
formData.pdate,
formData.title,
formData.uid,
formData.description,
'',
formData.share,
formData.guid
);
}
if (response.Success) {
setShowModal(false);
loadData();
} else {
alert(response.Message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('저장 오류:', error);
alert('서버 연결에 실패했습니다: ' + (error instanceof Error ? error.message : String(error)));
} finally {
setProcessing(false);
}
};
// 삭제
const handleDelete = async (idx: number) => {
if (!confirm('정말로 이 메모를 삭제하시겠습니까?')) return;
setProcessing(true);
try {
const response = await comms.deleteNote(idx);
if (response.Success) {
alert('삭제되었습니다.');
loadData();
} else {
alert(response.Message || '삭제에 실패했습니다.');
}
} catch (error) {
console.error('삭제 오류:', error);
alert('서버 연결에 실패했습니다.');
} finally {
setProcessing(false);
}
};
// 날짜 포맷 (YY.MM.DD)
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 filteredList = noteList.filter(item => {
if (!searchKey.trim()) return true;
const search = searchKey.toLowerCase();
return (
item.title?.toLowerCase().includes(search) ||
item.description?.toLowerCase().includes(search) ||
item.uid?.toLowerCase().includes(search)
);
});
// 페이징 계산
const totalPages = Math.ceil(filteredList.length / pageSize);
const paginatedList = filteredList.slice(
(currentPage - 1) * pageSize,
currentPage * pageSize
);
// 검색 시 페이지 초기화
const handleSearchWithReset = () => {
setCurrentPage(1);
handleSearch();
};
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">
<label className="text-white/70 text-sm font-medium whitespace-nowrap"></label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-36 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
<span className="text-white/70">~</span>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-36 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
</div>
<div className="flex items-center gap-2">
<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' && handleSearchWithReset()}
placeholder="제목, 내용, 작성자 등"
className="w-60 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={handleSearchWithReset}
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={openAddModal}
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>
{/* 메모 리스트 */}
<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">{filteredList.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>
) : filteredList.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>
) : (
paginatedList.map((item) => (
<div
key={item.idx}
className="px-6 py-3 hover:bg-white/5 transition-colors cursor-pointer group"
onClick={() => handleNoteClick(item)}
>
<div className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3 flex-1 min-w-0">
{item.share ? (
<Share2 className="w-4 h-4 text-green-400 flex-shrink-0" />
) : (
<Lock className="w-4 h-4 text-blue-400 flex-shrink-0" />
)}
<p className="text-white text-sm font-medium truncate flex-1">
{(item.title || '제목 없음').length > 15 ? `${(item.title || '제목 없음').substring(0, 15)}...` : (item.title || '제목 없음')}
</p>
</div>
<div className="flex items-center gap-4 flex-shrink-0">
<span className="text-white/60 text-xs">{item.uid || '-'}</span>
<span className="text-white/60 text-xs">{formatDate(item.pdate)}</span>
<span className="text-white/50 text-xs"> {item.viewcount || 0}</span>
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={(e) => {
e.stopPropagation();
openEditModal(item);
}}
className="text-white/40 hover:text-primary-400 transition-colors"
title="편집"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
handleDelete(item.idx);
}}
className="text-white/40 hover:text-red-400 transition-colors"
title="삭제"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
))
)}
</div>
{/* 페이징 */}
{totalPages > 1 && (
<div className="px-6 py-4 border-t border-white/10 flex items-center justify-between">
<div className="text-white/50 text-sm">
{filteredList.length} {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, filteredList.length)}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentPage(1)}
disabled={currentPage === 1}
className="px-3 py-1 rounded bg-white/10 text-white/70 hover:bg-white/20 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
«
</button>
<button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="px-3 py-1 rounded bg-white/10 text-white/70 hover:bg-white/20 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
</button>
<span className="text-white/70 px-3">
{currentPage} / {totalPages}
</span>
<button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="px-3 py-1 rounded bg-white/10 text-white/70 hover:bg-white/20 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
</button>
<button
onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages}
className="px-3 py-1 rounded bg-white/10 text-white/70 hover:bg-white/20 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
»
</button>
</div>
</div>
)}
</div>
{/* 메모 보기 모달 */}
<NoteViewModal
isOpen={showViewModal}
note={selectedNote}
onClose={() => setShowViewModal(false)}
onEdit={(note) => {
setShowViewModal(false);
setEditingItem(note);
setShowModal(true);
}}
onDelete={(note) => {
setShowViewModal(false);
handleDelete(note.idx);
}}
/>
{/* 추가/수정 모달 */}
<NoteEditModal
isOpen={showModal}
editingItem={editingItem}
processing={processing}
onClose={() => setShowModal(false)}
onSave={handleSave}
/>
</div>
);
}

View File

@@ -0,0 +1,219 @@
import { useState, useEffect } from 'react';
import { FileText, Search, RefreshCw, Calendar, User } from 'lucide-react';
import { comms } from '@/communication';
import { BoardItem } from '@/types';
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);
useEffect(() => {
loadData();
}, []);
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);
setShowModal(true);
}
} 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>
</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-4 hover:bg-white/5 transition-colors cursor-pointer"
onClick={() => handleRowClick(item)}
>
<div className="flex items-center justify-between gap-4">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{item.header && (
<span className="px-2 py-0.5 bg-primary-500/20 text-primary-400 text-xs rounded">
{item.header}
</span>
)}
{item.cate && (
<span className="px-2 py-0.5 bg-white/10 text-white/70 text-xs rounded">
{item.cate}
</span>
)}
</div>
<h4 className="text-white font-medium mb-1">{item.title}</h4>
<p className="text-white/60 text-sm line-clamp-1">{item.contents}</p>
</div>
<div className="flex flex-col items-end gap-1 flex-shrink-0">
<div className="flex items-center text-white/60 text-xs">
<User className="w-3 h-3 mr-1" />
{item.wuid_name || item.wuid}
</div>
<div className="flex items-center text-white/60 text-xs">
<Calendar className="w-3 h-3 mr-1" />
{formatDate(item.wdate)}
</div>
</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 px-6 py-4 border-t border-white/10 bg-white/5">
<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>
);
}

View File

@@ -12,3 +12,4 @@ export { MonthlyWorkPage } from './MonthlyWork';
export { MailFormPage } from './MailForm';
export { UserGroupPage } from './UserGroup';
export { default as UserAuthPage } from './UserAuth';
export { Note } from './Note';

View File

@@ -448,6 +448,20 @@ export interface MachineBridgeInterface {
Project_GetList(statusFilter: string, category: string, process: string, userFilter: string, yearStart: string, yearEnd: string, dateType: string): Promise<string>;
Project_GetHistory(projectIdx: number): Promise<string>;
Project_GetDailyMemo(projectIdx: number): Promise<string>;
// Note API (메모장)
Note_GetList(startDate: string, endDate: string, uid: string): Promise<string>;
Note_GetDetail(idx: number): Promise<string>;
Note_Add(pdate: string, title: string, uid: string, description: string, description2: string, share: boolean, guid: string): Promise<string>;
Note_Edit(idx: number, pdate: string, title: string, uid: string, description: string, description2: string, share: boolean, guid: string): Promise<string>;
Note_Delete(idx: number): Promise<string>;
// Board API (게시판 - 패치내역 등)
Board_GetList(bidx: number, searchKey: string): Promise<string>;
Board_GetDetail(idx: number): Promise<string>;
// Mail API (메일 발신 내역)
Mail_GetList(startDate: string, endDate: string, searchKey: string): Promise<string>;
}
// 사용자 권한 정보 타입
@@ -512,6 +526,23 @@ export interface HolidayItem {
wdate?: string;
}
// 메모장 관련 타입 (EETGW_Note 테이블)
export interface NoteItem {
idx: number;
gcode: string;
pdate: string; // 날짜
title: string; // 제목
uid: string; // 작성자 ID
description: string; // 내용 (plain text)
description2: string; // 내용 (RTF format - not used in web)
share: boolean; // 공유 여부
wuid: string; // 등록자 ID
wdate: string; // 등록일
guid: string; // 폴더 GUID
viewcount?: number; // 조회수
viewdate?: string; // 최종 조회일
}
// 메일양식 항목 타입
export interface MailFormItem {
idx: number;
@@ -792,3 +823,39 @@ export interface JobReportDayData {
holidays: HolidayItem[];
}
// Board 게시판 타입 (패치내역 등)
export interface BoardItem {
idx: number;
bidx: number;
header: string;
cate: string;
title: string;
contents: string;
file: string;
guid: string;
url: string;
wuid: string;
wdate: string | null;
project: string;
pidx: number;
gcode: string;
close: boolean;
remark: string;
wuid_name: string;
}
// Mail 발신 내역 타입
export interface MailItem {
idx: number;
gcode: string;
uid: string;
subject: string;
htmlbody: string;
fromlist: string;
tolist: string;
cclist: string;
bcclist: string;
project: string;
cate: string;
wdate: string | null;
}