feat: React 프론트엔드 기능 대폭 확장

- 월별근무표: 휴일/근무일 관리, 자동 초기화
- 메일양식: 템플릿 CRUD, To/CC/BCC 설정
- 그룹정보: 부서 관리, 비트 연산 기반 권한 설정
- 업무일지: 수정 성공 메시지 제거, 오늘 근무시간 필터링 수정
- 웹소켓 메시지 type 충돌 버그 수정

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
backuppc
2025-11-27 17:25:31 +09:00
parent b57af6dad7
commit c9b5d756e1
65 changed files with 14028 additions and 467 deletions

28
Project/frontend/.gitignore vendored Normal file
View File

@@ -0,0 +1,28 @@
# Dependencies
node_modules/
# Build output
dist/
# Local env files
.env
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# Editor directories and files
.idea/
.vscode/
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
# OS generated files
.DS_Store
Thumbs.db

148
Project/frontend/README.md Normal file
View File

@@ -0,0 +1,148 @@
# GroupWare React Frontend
GroupWare 시스템의 React 기반 프론트엔드입니다.
## 기술 스택
- **React 18** - UI 라이브러리
- **TypeScript** - 타입 안정성
- **Vite** - 빌드 도구 및 개발 서버
- **Tailwind CSS** - 스타일링
- **React Router v7** - 라우팅
- **Lucide React** - 아이콘
## 프로젝트 구조
```
frontend/
├── src/
│ ├── components/
│ │ └── layout/ # 레이아웃 컴포넌트
│ │ ├── Header.tsx
│ │ ├── Layout.tsx
│ │ └── Navigation.tsx
│ ├── pages/ # 페이지 컴포넌트
│ │ ├── Dashboard.tsx
│ │ ├── Todo.tsx
│ │ └── Placeholder.tsx
│ ├── communication.ts # 통신 레이어 (WebView2 + WebSocket)
│ ├── types.ts # TypeScript 타입 정의
│ ├── App.tsx # 메인 앱 컴포넌트
│ ├── main.tsx # 엔트리 포인트
│ └── index.css # 글로벌 스타일
├── package.json
├── vite.config.ts
├── tailwind.config.js
├── tsconfig.json
├── build.bat # 프로덕션 빌드 스크립트
└── run-dev.bat # 개발 서버 실행 스크립트
```
## 개발 환경 설정
### 1. 의존성 설치
```bash
cd Project/frontend
npm install
```
### 2. 개발 서버 실행
```bash
npm run dev
# 또는
run-dev.bat
```
개발 서버는 `http://localhost:5173`에서 실행됩니다.
### 3. 핫 리로드 개발
개발 모드에서는 WebSocket(포트 8082)을 통해 C# 백엔드와 통신합니다.
GroupWare 애플리케이션에서 WebSocket 서버가 실행 중이어야 합니다.
## 프로덕션 빌드
### 빌드 실행
```bash
npm run build
# 또는
build.bat
```
빌드 결과물은 `dist/` 폴더에 생성되고,
`build.bat`을 사용하면 자동으로 `wwwroot/react-app/`으로 복사됩니다.
### 접근 URL
- **개발**: `http://localhost:5173`
- **프로덕션**: `http://localhost:7979/react-app/`
## 통신 방식
### 듀얼 모드 통신
이 프로젝트는 두 가지 통신 방식을 지원합니다:
| 환경 | 통신 방식 | 포트 |
|------|-----------|------|
| WebView2 (프로덕션) | HostObject 직접 호출 | - |
| Browser (개발) | WebSocket | 8082 |
### 자동 감지
`communication.ts`에서 실행 환경을 자동으로 감지하여 적절한 통신 방식을 사용합니다:
```typescript
const isWebView = typeof window !== 'undefined' && !!window.chrome?.webview;
if (isWebView) {
// WebView2 HostObject 사용
const result = await machine.Todo_GetTodos();
} else {
// WebSocket 사용
ws.send(JSON.stringify({ type: 'GET_TODOS' }));
}
```
## 페이지 목록
| 경로 | 페이지 | 상태 |
|------|--------|------|
| `/` | 대시보드 | 완료 |
| `/todo` | 할일 관리 | 완료 |
| `/kuntae` | 근태 관리 | 개발 예정 |
| `/jobreport` | 업무 일지 | 개발 예정 |
| `/project` | 프로젝트 | 개발 예정 |
| `/common` | 공용 코드 | 개발 예정 |
## 스타일 가이드
### 색상 팔레트
- **Primary**: Blue (`#3b82f6`)
- **Success**: Green (`#22c55e`)
- **Warning**: Amber (`#f59e0b`)
- **Danger**: Red (`#ef4444`)
### Glass Effect
```css
.glass-effect {
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.18);
}
```
## C# WebSocket 서버 설정
`MachineBridge/WebSocketServer.cs`를 참고하여 WebSocket 서버를 시작하세요:
```csharp
// 예시 코드
var wsServer = new GroupWareWebSocketServer("http://localhost:8082/", bridge);
wsServer.Start();
```

View File

@@ -0,0 +1,50 @@
@echo off
echo ========================================
echo GroupWare Frontend Build Script
echo ========================================
REM 현재 디렉토리 저장
set CURRENT_DIR=%CD%
REM frontend 폴더로 이동
cd /d "%~dp0"
echo.
echo [1/3] Installing dependencies...
call npm install
if %ERRORLEVEL% NEQ 0 (
echo ERROR: npm install failed
pause
exit /b 1
)
echo.
echo [2/3] Building production...
call npm run build
if %ERRORLEVEL% NEQ 0 (
echo ERROR: npm build failed
pause
exit /b 1
)
echo.
echo [3/3] Copying dist to wwwroot...
REM 기존 react 폴더 삭제 (있으면)
if exist "..\Web\wwwroot\react-app" rmdir /s /q "..\Web\wwwroot\react-app"
REM dist 폴더를 wwwroot\react-app으로 복사
xcopy /e /i /y "dist" "..\Web\wwwroot\react-app"
echo.
echo ========================================
echo Build completed successfully!
echo Output: Project\Web\wwwroot\react-app
echo ========================================
echo.
echo Access via: http://localhost:7979/react-app/
echo.
REM 원래 디렉토리로 복귀
cd /d "%CURRENT_DIR%"
pause

View File

@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<title>GroupWare</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

2718
Project/frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
{
"name": "groupware-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"clsx": "^2.1.0",
"lucide-react": "^0.303.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^7.9.6",
"tailwind-merge": "^2.2.0"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.16",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"typescript": "^5.3.3",
"vite": "^5.0.10"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,18 @@
@echo off
echo ========================================
echo GroupWare Frontend Development Server
echo ========================================
REM frontend 폴더로 이동
cd /d "%~dp0"
echo.
echo Starting Vite development server...
echo.
echo [INFO] Make sure GroupWare application is running
echo [INFO] WebSocket server should be on port 8082
echo.
echo Press Ctrl+C to stop the server
echo.
call npm run dev

View File

@@ -0,0 +1,99 @@
import { useState, useEffect } from 'react';
import { HashRouter, Routes, Route } from 'react-router-dom';
import { Layout } from '@/components/layout';
import { Dashboard, Todo, Kuntae, Jobreport, PlaceholderPage, Login, CommonCodePage, ItemsPage, UserListPage, MonthlyWorkPage, MailFormPage, UserGroupPage } from '@/pages';
import { comms } from '@/communication';
import { UserInfo } from '@/types';
import { Loader2 } from 'lucide-react';
export default function App() {
const [isConnected, setIsConnected] = useState(false);
const [isLoggedIn, setIsLoggedIn] = useState<boolean | null>(null); // null = 체크 중
const [user, setUser] = useState<UserInfo | null>(null);
useEffect(() => {
// 통신 상태 구독
const unsubscribe = comms.subscribe((msg: unknown) => {
const message = msg as { type?: string; connected?: boolean };
if (message?.type === 'CONNECTION_STATE') {
setIsConnected(message.connected ?? false);
// 연결되면 로그인 상태 체크
if (message.connected) {
checkLoginStatus();
}
}
});
// 초기 연결 상태 설정
setIsConnected(comms.getConnectionState());
// 연결되어 있으면 바로 로그인 상태 체크
if (comms.getConnectionState()) {
checkLoginStatus();
}
return () => {
unsubscribe();
};
}, []);
const checkLoginStatus = async () => {
try {
const result = await comms.checkLoginStatus();
if (result.Success) {
setIsLoggedIn(result.IsLoggedIn);
setUser(result.User);
} else {
setIsLoggedIn(false);
setUser(null);
}
} catch (err) {
console.error('로그인 상태 체크 실패:', err);
setIsLoggedIn(false);
setUser(null);
}
};
const handleLoginSuccess = () => {
checkLoginStatus();
};
// 로그인 상태 체크 중
if (isLoggedIn === null) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 flex items-center justify-center">
<div className="text-center">
<Loader2 className="w-10 h-10 text-white animate-spin mx-auto mb-4" />
<p className="text-white/70"> ...</p>
</div>
</div>
);
}
// 로그인 안됨 → 로그인 화면 표시
if (!isLoggedIn) {
return <Login onLoginSuccess={handleLoginSuccess} />;
}
// 로그인 됨 → 메인 앱 표시
return (
<HashRouter>
<Routes>
<Route element={<Layout isConnected={isConnected} user={user} />}>
<Route path="/" element={<Dashboard />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/todo" element={<Todo />} />
<Route path="/kuntae" element={<Kuntae />} />
<Route path="/jobreport" element={<Jobreport />} />
<Route path="/project" element={<PlaceholderPage title="프로젝트" />} />
<Route path="/common" element={<CommonCodePage />} />
<Route path="/items" element={<ItemsPage />} />
<Route path="/user/list" element={<UserListPage />} />
<Route path="/monthly-work" element={<MonthlyWorkPage />} />
<Route path="/mail-form" element={<MailFormPage />} />
<Route path="/user-group" element={<UserGroupPage />} />
</Route>
</Routes>
</HashRouter>
);
}

View File

@@ -0,0 +1,755 @@
import { MachineBridgeInterface, ApiResponse, TodoModel, PurchaseCount, HolyUser, HolyRequestUser, PurchaseItem, KuntaeModel, LoginStatusResponse, LoginResult, UserGroup, PreviousLoginInfo, UserInfoDetail, GroupUser, UserLevelInfo, UserFullData, JobReportItem, JobReportUser, CommonCodeGroup, CommonCode, ItemInfo, JobReportPermission, AppVersionInfo, JobTypeItem, HolidayItem, MailFormItem, UserGroupItem, PermissionInfo } from './types';
// WebView2 환경인지 체크
const isWebView = typeof window !== 'undefined' && !!window.chrome?.webview;
// 비동기 프록시 캐싱 (한 번만 초기화)
const machine: MachineBridgeInterface | null = isWebView
? window.chrome!.webview!.hostObjects.machine
: null;
type MessageCallback = (data: unknown) => void;
class CommunicationLayer {
private listeners: MessageCallback[] = [];
private ws: WebSocket | null = null;
private isConnected = false;
private wsUrl = 'ws://localhost:8082'; // GroupWare WebSocket 포트
constructor() {
if (isWebView) {
console.log("[COMM] Running in WebView2 Mode (HostObject)");
this.isConnected = true;
window.chrome!.webview!.addEventListener('message', (event: MessageEvent) => {
this.notifyListeners(event.data);
});
// WebView2 환경에서도 연결 상태 알림
setTimeout(() => {
this.notifyListeners({ type: 'CONNECTION_STATE', connected: true });
}, 0);
} else {
console.log("[COMM] Running in Browser Mode (WebSocket)");
this.connectWebSocket();
}
}
public isWebViewMode(): boolean {
return isWebView;
}
private connectWebSocket() {
this.ws = new WebSocket(this.wsUrl);
this.ws.onopen = () => {
console.log("[COMM] WebSocket Connected");
this.isConnected = true;
this.notifyListeners({ type: 'CONNECTION_STATE', connected: true });
};
this.ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
this.notifyListeners(data);
} catch (e) {
console.error("[COMM] JSON Parse Error", e);
}
};
this.ws.onclose = () => {
console.log("[COMM] WebSocket Closed. Reconnecting...");
this.isConnected = false;
this.notifyListeners({ type: 'CONNECTION_STATE', connected: false });
setTimeout(() => this.connectWebSocket(), 2000);
};
this.ws.onerror = (err) => {
console.error("[COMM] WebSocket Error", err);
};
}
private notifyListeners(data: unknown) {
this.listeners.forEach(cb => cb(data));
}
public subscribe(callback: MessageCallback) {
this.listeners.push(callback);
return () => {
this.listeners = this.listeners.filter(cb => cb !== callback);
};
}
public getConnectionState(): boolean {
return this.isConnected;
}
// WebSocket 요청-응답 헬퍼
private wsRequest<T>(requestType: string, responseType: string, params?: Record<string, unknown>): Promise<T> {
return new Promise((resolve, reject) => {
if (!this.isConnected) {
setTimeout(() => {
if (!this.isConnected) reject(new Error("WebSocket connection timeout"));
}, 2000);
}
const timeoutId = setTimeout(() => {
this.listeners = this.listeners.filter(cb => cb !== handler);
reject(new Error(`${requestType} timeout`));
}, 10000);
const handler = (data: unknown) => {
const msg = data as { type: string; data?: T; Success?: boolean; Message?: string };
if (msg.type === responseType) {
clearTimeout(timeoutId);
this.listeners = this.listeners.filter(cb => cb !== handler);
resolve(msg.data as T);
}
};
this.listeners.push(handler);
this.ws?.send(JSON.stringify({ ...params, type: requestType }));
});
}
// ===== Todo API =====
public async getTodos(): Promise<ApiResponse<TodoModel[]>> {
if (isWebView && machine) {
const result = await machine.Todo_GetTodos();
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse<TodoModel[]>>('GET_TODOS', 'TODOS_DATA');
}
}
public async getTodo(id: number): Promise<ApiResponse<TodoModel>> {
if (isWebView && machine) {
const result = await machine.Todo_GetTodo(id);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse<TodoModel>>('GET_TODO', 'TODO_DATA', { id });
}
}
public async createTodo(
title: string,
remark: string,
expire: string | null,
seqno: number,
flag: boolean,
request: string | null,
status: string
): Promise<ApiResponse<{ idx: number }>> {
if (isWebView && machine) {
const result = await machine.CreateTodo(title, remark, expire, seqno, flag, request, status);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse<{ idx: number }>>('CREATE_TODO', 'TODO_CREATED', {
title, remark, expire, seqno, flag, request, status
});
}
}
public async updateTodo(
idx: number,
title: string,
remark: string,
expire: string | null,
seqno: number,
flag: boolean,
request: string | null,
status: string
): Promise<ApiResponse> {
if (isWebView && machine) {
const result = await machine.Todo_UpdateTodo(idx, title, remark, expire, seqno, flag, request, status);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse>('UPDATE_TODO', 'TODO_UPDATED', {
idx, title, remark, expire, seqno, flag, request, status
});
}
}
public async deleteTodo(id: number): Promise<ApiResponse> {
if (isWebView && machine) {
const result = await machine.Todo_DeleteTodo(id);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse>('DELETE_TODO', 'TODO_DELETED', { id });
}
}
public async getUrgentTodos(): Promise<ApiResponse<TodoModel[]>> {
if (isWebView && machine) {
const result = await machine.GetUrgentTodos();
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse<TodoModel[]>>('GET_URGENT_TODOS', 'URGENT_TODOS_DATA');
}
}
// ===== Dashboard API =====
public async getPurchaseWaitCount(): Promise<PurchaseCount> {
if (isWebView && machine) {
const result = await machine.GetPurchaseWaitCount();
return JSON.parse(result);
} else {
return this.wsRequest<PurchaseCount>('GET_PURCHASE_WAIT_COUNT', 'PURCHASE_WAIT_COUNT_DATA');
}
}
public async getTodayCountH(): Promise<number> {
if (isWebView && machine) {
const result = await machine.TodayCountH();
return parseInt(result, 10);
} else {
const response = await this.wsRequest<{ count: number }>('GET_TODAY_COUNT_H', 'TODAY_COUNT_H_DATA');
return response.count;
}
}
public async getHolyUser(): Promise<HolyUser[]> {
if (isWebView && machine) {
const result = await machine.GetHolyUser();
return JSON.parse(result);
} else {
return this.wsRequest<HolyUser[]>('GET_HOLY_USER', 'HOLY_USER_DATA');
}
}
public async getHolyRequestUser(): Promise<HolyRequestUser[]> {
if (isWebView && machine) {
const result = await machine.GetHolyRequestUser();
return JSON.parse(result);
} else {
return this.wsRequest<HolyRequestUser[]>('GET_HOLY_REQUEST_USER', 'HOLY_REQUEST_USER_DATA');
}
}
public async getPurchaseNRList(): Promise<PurchaseItem[]> {
if (isWebView && machine) {
const result = await machine.GetPurchaseNRList();
return JSON.parse(result);
} else {
return this.wsRequest<PurchaseItem[]>('GET_PURCHASE_NR_LIST', 'PURCHASE_NR_LIST_DATA');
}
}
public async getPurchaseCRList(): Promise<PurchaseItem[]> {
if (isWebView && machine) {
const result = await machine.GetPurchaseCRList();
return JSON.parse(result);
} else {
return this.wsRequest<PurchaseItem[]>('GET_PURCHASE_CR_LIST', 'PURCHASE_CR_LIST_DATA');
}
}
public async getHolydayRequestCount(): Promise<{ HOLY: number; Message?: string }> {
if (isWebView && machine) {
const result = await machine.GetHolydayRequestCount();
return JSON.parse(result);
} else {
return this.wsRequest<{ HOLY: number; Message?: string }>('GET_HOLYDAY_REQUEST_COUNT', 'HOLYDAY_REQUEST_COUNT_DATA');
}
}
public async getCurrentUserCount(): Promise<{ Count: number; Message?: string }> {
if (isWebView && machine) {
const result = await machine.GetCurrentUserCount();
return JSON.parse(result);
} else {
return this.wsRequest<{ Count: number; Message?: string }>('GET_CURRENT_USER_COUNT', 'CURRENT_USER_COUNT_DATA');
}
}
// ===== Kuntae API =====
public async getKuntaeList(sd: string, ed: string): Promise<ApiResponse<KuntaeModel[]>> {
if (isWebView && machine) {
const result = await machine.Kuntae_GetList(sd, ed);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse<KuntaeModel[]>>('GET_KUNTAE_LIST', 'KUNTAE_LIST_DATA', { sd, ed });
}
}
public async deleteKuntae(id: number): Promise<ApiResponse> {
if (isWebView && machine) {
const result = await machine.Kuntae_Delete(id);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse>('DELETE_KUNTAE', 'KUNTAE_DELETED', { id });
}
}
// ===== Login API =====
public async checkLoginStatus(): Promise<LoginStatusResponse> {
if (isWebView && machine) {
const result = await machine.CheckLoginStatus();
return JSON.parse(result);
} else {
return this.wsRequest<LoginStatusResponse>('CHECK_LOGIN_STATUS', 'LOGIN_STATUS_DATA');
}
}
public async login(gcode: string, id: string, password: string, rememberMe: boolean): Promise<LoginResult> {
if (isWebView && machine) {
const result = await machine.Login(gcode, id, password, rememberMe);
return JSON.parse(result);
} else {
return this.wsRequest<LoginResult>('LOGIN', 'LOGIN_RESULT', { gcode, id, password, rememberMe });
}
}
public async logout(): Promise<ApiResponse> {
if (isWebView && machine) {
const result = await machine.Logout();
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse>('LOGOUT', 'LOGOUT_RESULT');
}
}
public async getUserGroups(): Promise<UserGroup[]> {
if (isWebView && machine) {
const result = await machine.GetUserGroups();
return JSON.parse(result);
} else {
return this.wsRequest<UserGroup[]>('GET_USER_GROUPS', 'USER_GROUPS_DATA');
}
}
public async getPreviousLoginInfo(): Promise<PreviousLoginInfo> {
if (isWebView && machine) {
const result = await machine.GetPreviousLoginInfo();
return JSON.parse(result);
} else {
return this.wsRequest<PreviousLoginInfo>('GET_PREVIOUS_LOGIN_INFO', 'PREVIOUS_LOGIN_INFO_DATA');
}
}
// ===== User API =====
public async getCurrentUserInfo(): Promise<ApiResponse<UserInfoDetail>> {
if (isWebView && machine) {
const result = await machine.GetCurrentUserInfo();
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse<UserInfoDetail>>('GET_CURRENT_USER_INFO', 'CURRENT_USER_INFO_DATA');
}
}
public async getUserInfoById(userId: string): Promise<ApiResponse<UserInfoDetail>> {
if (isWebView && machine) {
const result = await machine.GetUserInfoById(userId);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse<UserInfoDetail>>('GET_USER_INFO_BY_ID', 'USER_INFO_DATA', { userId });
}
}
public async saveUserInfo(userData: UserInfoDetail): Promise<ApiResponse> {
if (isWebView && machine) {
const result = await machine.SaveUserInfo(JSON.stringify(userData));
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse>('SAVE_USER_INFO', 'USER_INFO_SAVED', { userData });
}
}
public async changePassword(oldPassword: string, newPassword: string): Promise<ApiResponse> {
if (isWebView && machine) {
const result = await machine.ChangePassword(oldPassword, newPassword);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse>('CHANGE_PASSWORD', 'PASSWORD_CHANGED', { oldPassword, newPassword });
}
}
// ===== Common Code API =====
public async getCommonGroups(): Promise<CommonCodeGroup[]> {
if (isWebView && machine) {
const result = await machine.Common_GetGroups();
return JSON.parse(result);
} else {
return this.wsRequest<CommonCodeGroup[]>('COMMON_GET_GROUPS', 'COMMON_GROUPS_DATA');
}
}
public async getCommonList(grp: string): Promise<CommonCode[]> {
if (isWebView && machine) {
const result = await machine.Common_GetList(grp);
return JSON.parse(result);
} else {
return this.wsRequest<CommonCode[]>('COMMON_GET_LIST', 'COMMON_LIST_DATA', { grp });
}
}
public async saveCommon(data: CommonCode): Promise<ApiResponse> {
if (isWebView && machine) {
const result = await machine.Common_Save(
data.idx, data.grp, data.code, data.svalue,
data.ivalue, data.fvalue, data.svalue2 || '', data.memo
);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse>('COMMON_SAVE', 'COMMON_SAVED', data as unknown as Record<string, unknown>);
}
}
public async deleteCommon(idx: number): Promise<ApiResponse> {
if (isWebView && machine) {
const result = await machine.Common_Delete(idx);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse>('COMMON_DELETE', 'COMMON_DELETED', { idx });
}
}
// ===== Items API =====
public async getItemCategories(): Promise<ApiResponse<string[]>> {
if (isWebView && machine) {
const result = await machine.Items_GetCategories();
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse<string[]>>('ITEMS_GET_CATEGORIES', 'ITEMS_CATEGORIES_DATA');
}
}
public async getItemList(category: string, searchKey: string): Promise<ItemInfo[]> {
if (isWebView && machine) {
const result = await machine.Items_GetList(category, searchKey);
return JSON.parse(result);
} else {
return this.wsRequest<ItemInfo[]>('ITEMS_GET_LIST', 'ITEMS_LIST_DATA', { category, searchKey });
}
}
public async saveItem(data: ItemInfo): Promise<ApiResponse> {
if (isWebView && machine) {
const result = await machine.Items_Save(
data.idx, data.sid, data.cate, data.name, data.model,
data.scale, data.unit, data.price, data.supply,
data.manu, data.storage, data.disable, data.memo
);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse>('ITEMS_SAVE', 'ITEMS_SAVED', data as unknown as Record<string, unknown>);
}
}
public async deleteItem(idx: number): Promise<ApiResponse> {
if (isWebView && machine) {
const result = await machine.Items_Delete(idx);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse>('ITEMS_DELETE', 'ITEMS_DELETED', { idx });
}
}
// ===== UserList API =====
public async getCurrentUserLevel(): Promise<ApiResponse<UserLevelInfo>> {
if (isWebView && machine) {
const result = await machine.UserList_GetCurrentLevel();
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse<UserLevelInfo>>('USERLIST_GET_CURRENT_LEVEL', 'USERLIST_CURRENT_LEVEL_DATA');
}
}
public async getUserList(process: string): Promise<GroupUser[]> {
if (isWebView && machine) {
const result = await machine.UserList_GetList(process);
return JSON.parse(result);
} else {
return this.wsRequest<GroupUser[]>('USERLIST_GET_LIST', 'USERLIST_LIST_DATA', { process });
}
}
public async getUserListUser(userId: string): Promise<ApiResponse<GroupUser>> {
if (isWebView && machine) {
const result = await machine.UserList_GetUser(userId);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse<GroupUser>>('USERLIST_GET_USER', 'USERLIST_USER_DATA', { userId });
}
}
public async saveGroupUser(
userId: string, dept: string, level: number,
useUserState: boolean, useJobReport: boolean, exceptHoly: boolean
): Promise<ApiResponse> {
if (isWebView && machine) {
const result = await machine.UserList_SaveGroupUser(userId, dept, level, useUserState, useJobReport, exceptHoly);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse>('USERLIST_SAVE_GROUP_USER', 'USERLIST_SAVED', {
userId, dept, level, useUserState, useJobReport, exceptHoly
});
}
}
public async saveUserFull(userData: UserFullData): Promise<ApiResponse> {
if (isWebView && machine) {
const result = await machine.UserList_SaveUserFull(JSON.stringify(userData));
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse>('USERLIST_SAVE_USER_FULL', 'USERLIST_USER_FULL_SAVED', { userData });
}
}
public async deleteGroupUser(userId: string): Promise<ApiResponse> {
if (isWebView && machine) {
const result = await machine.UserList_DeleteGroupUser(userId);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse>('USERLIST_DELETE_GROUP_USER', 'USERLIST_DELETED', { userId });
}
}
// ===== JobReport API (JobReport 뷰/테이블) =====
public async getJobReportList(sd: string, ed: string, uid: string = '', searchKey: string = ''): Promise<ApiResponse<JobReportItem[]>> {
if (isWebView && machine) {
const result = await machine.Jobreport_GetList(sd, ed, uid, '', searchKey);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse<JobReportItem[]>>('JOBREPORT_GET_LIST', 'JOBREPORT_LIST_DATA', { sd, ed, uid, searchKey });
}
}
public async getJobReportUsers(): Promise<JobReportUser[]> {
if (isWebView && machine) {
const result = await machine.Jobreport_GetUsers();
return JSON.parse(result);
} else {
return this.wsRequest<JobReportUser[]>('JOBREPORT_GET_USERS', 'JOBREPORT_USERS_DATA');
}
}
public async getJobReportDetail(idx: number): Promise<ApiResponse<JobReportItem>> {
if (isWebView && machine) {
const result = await machine.Jobreport_GetDetail(idx);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse<JobReportItem>>('JOBREPORT_GET_DETAIL', 'JOBREPORT_DETAIL_DATA', { idx });
}
}
public async addJobReport(
pdate: string, projectName: string, requestpart: string, package_: string,
type: string, process: string, status: string, description: string,
hrs: number, ot: number, jobgrp: string, tag: string
): Promise<ApiResponse> {
if (isWebView && machine) {
const result = await machine.Jobreport_Add(pdate, projectName, requestpart, package_, type, process, status, description, hrs, ot, jobgrp, tag);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse>('JOBREPORT_ADD', 'JOBREPORT_ADDED', {
pdate, projectName, requestpart, package: package_, type, process, status, description, hrs, ot, jobgrp, tag
});
}
}
public async editJobReport(
idx: number, pdate: string, projectName: string, requestpart: string, package_: string,
type: string, process: string, status: string, description: string,
hrs: number, ot: number, jobgrp: string, tag: string
): Promise<ApiResponse> {
if (isWebView && machine) {
const result = await machine.Jobreport_Edit(idx, pdate, projectName, requestpart, package_, type, process, status, description, hrs, ot, jobgrp, tag);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse>('JOBREPORT_EDIT', 'JOBREPORT_EDITED', {
idx, pdate, projectName, requestpart, package: package_, type, process, status, description, hrs, ot, jobgrp, tag
});
}
}
public async deleteJobReport(idx: number): Promise<ApiResponse> {
if (isWebView && machine) {
const result = await machine.Jobreport_Delete(idx);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse>('JOBREPORT_DELETE', 'JOBREPORT_DELETED', { idx });
}
}
public async getJobReportPermission(targetUserId: string): Promise<JobReportPermission> {
if (isWebView && machine) {
const result = await machine.Jobreport_GetPermission(targetUserId);
return JSON.parse(result);
} else {
return this.wsRequest<JobReportPermission>('JOBREPORT_GET_PERMISSION', 'JOBREPORT_PERMISSION', { targetUserId });
}
}
public async getJobTypes(process: string = ''): Promise<ApiResponse<JobTypeItem[]>> {
if (isWebView && machine) {
const result = await machine.Jobreport_GetJobTypes(process);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse<JobTypeItem[]>>('JOBREPORT_GET_JOBTYPES', 'JOBREPORT_JOBTYPES', { process });
}
}
public async getAppVersion(): Promise<AppVersionInfo> {
if (isWebView && machine) {
const result = await machine.GetAppVersion();
return JSON.parse(result);
} else {
return this.wsRequest<AppVersionInfo>('GET_APP_VERSION', 'APP_VERSION', {});
}
}
// ===== Holiday API (월별근무표) =====
public async getHolidayList(month: string): Promise<ApiResponse<HolidayItem[]>> {
if (isWebView && machine) {
const result = await machine.Holiday_GetList(month);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse<HolidayItem[]>>('HOLIDAY_GET_LIST', 'HOLIDAY_LIST_DATA', { month });
}
}
public async saveHolidays(month: string, holidays: HolidayItem[]): Promise<ApiResponse> {
if (isWebView && machine) {
const result = await machine.Holiday_Save(month, JSON.stringify(holidays));
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse>('HOLIDAY_SAVE', 'HOLIDAY_SAVED', { month, holidays });
}
}
public async initializeHoliday(month: string): Promise<ApiResponse<{ Created: boolean }>> {
if (isWebView && machine) {
const result = await machine.Holiday_Initialize(month);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse<{ Created: boolean }>>('HOLIDAY_INITIALIZE', 'HOLIDAY_INITIALIZED', { month });
}
}
// ===== MailForm API (메일양식) =====
public async getMailFormList(): Promise<ApiResponse<MailFormItem[]>> {
if (isWebView && machine) {
const result = await machine.MailForm_GetList();
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse<MailFormItem[]>>('MAILFORM_GET_LIST', 'MAILFORM_LIST_DATA');
}
}
public async getMailFormDetail(idx: number): Promise<ApiResponse<MailFormItem>> {
if (isWebView && machine) {
const result = await machine.MailForm_GetDetail(idx);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse<MailFormItem>>('MAILFORM_GET_DETAIL', 'MAILFORM_DETAIL_DATA', { idx });
}
}
public async addMailForm(
cate: string, title: string, tolist: string, bcc: string, cc: string,
subject: string, tail: string, body: string, selfTo: boolean, selfCC: boolean,
selfBCC: boolean, exceptmail: string, exceptmailcc: string
): Promise<ApiResponse> {
if (isWebView && machine) {
const result = await machine.MailForm_Add(cate, title, tolist, bcc, cc, subject, tail, body, selfTo, selfCC, selfBCC, exceptmail, exceptmailcc);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse>('MAILFORM_ADD', 'MAILFORM_ADDED', {
cate, title, tolist, bcc, cc, subject, tail, body, selfTo, selfCC, selfBCC, exceptmail, exceptmailcc
});
}
}
public async editMailForm(
idx: number, cate: string, title: string, tolist: string, bcc: string, cc: string,
subject: string, tail: string, body: string, selfTo: boolean, selfCC: boolean,
selfBCC: boolean, exceptmail: string, exceptmailcc: string
): Promise<ApiResponse> {
if (isWebView && machine) {
const result = await machine.MailForm_Edit(idx, cate, title, tolist, bcc, cc, subject, tail, body, selfTo, selfCC, selfBCC, exceptmail, exceptmailcc);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse>('MAILFORM_EDIT', 'MAILFORM_EDITED', {
idx, cate, title, tolist, bcc, cc, subject, tail, body, selfTo, selfCC, selfBCC, exceptmail, exceptmailcc
});
}
}
public async deleteMailForm(idx: number): Promise<ApiResponse> {
if (isWebView && machine) {
const result = await machine.MailForm_Delete(idx);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse>('MAILFORM_DELETE', 'MAILFORM_DELETED', { idx });
}
}
// ===== UserGroup API (그룹정보/권한설정) =====
public async getUserGroupList(): Promise<ApiResponse<UserGroupItem[]>> {
if (isWebView && machine) {
const result = await machine.UserGroup_GetList();
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse<UserGroupItem[]>>('USERGROUP_GET_LIST', 'USERGROUP_LIST_DATA');
}
}
public async addUserGroup(
dept: string, path_kj: string, permission: number, advpurchase: boolean,
advkisul: boolean, managerinfo: string, devinfo: string, usemail: boolean
): Promise<ApiResponse> {
if (isWebView && machine) {
const result = await machine.UserGroup_Add(dept, path_kj, permission, advpurchase, advkisul, managerinfo, devinfo, usemail);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse>('USERGROUP_ADD', 'USERGROUP_ADDED', {
dept, path_kj, permission, advpurchase, advkisul, managerinfo, devinfo, usemail
});
}
}
public async editUserGroup(
originalDept: string, dept: string, path_kj: string, permission: number,
advpurchase: boolean, advkisul: boolean, managerinfo: string, devinfo: string, usemail: boolean
): Promise<ApiResponse> {
if (isWebView && machine) {
const result = await machine.UserGroup_Edit(originalDept, dept, path_kj, permission, advpurchase, advkisul, managerinfo, devinfo, usemail);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse>('USERGROUP_EDIT', 'USERGROUP_EDITED', {
originalDept, dept, path_kj, permission, advpurchase, advkisul, managerinfo, devinfo, usemail
});
}
}
public async deleteUserGroup(dept: string): Promise<ApiResponse> {
if (isWebView && machine) {
const result = await machine.UserGroup_Delete(dept);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse>('USERGROUP_DELETE', 'USERGROUP_DELETED', { dept });
}
}
public async getPermissionInfo(): Promise<ApiResponse<PermissionInfo[]>> {
if (isWebView && machine) {
const result = await machine.UserGroup_GetPermissionInfo();
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse<PermissionInfo[]>>('USERGROUP_GET_PERMISSION_INFO', 'USERGROUP_PERMISSION_INFO');
}
}
}
export const comms = new CommunicationLayer();

View File

@@ -0,0 +1,244 @@
import { useState, useEffect } from 'react';
import { X, Save, Trash2 } from 'lucide-react';
import { ItemInfo } from '@/types';
interface ItemEditDialogProps {
item: ItemInfo | null;
isOpen: boolean;
onClose: () => void;
onSave: (item: ItemInfo) => Promise<void>;
onDelete: (idx: number) => Promise<void>;
}
export function ItemEditDialog({ item, isOpen, onClose, onSave, onDelete }: ItemEditDialogProps) {
const [editData, setEditData] = useState<ItemInfo | null>(null);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (item) {
setEditData({ ...item });
}
}, [item]);
if (!isOpen || !editData) return null;
const isNew = editData.idx === 0;
const handleSave = async () => {
if (!editData) return;
setSaving(true);
try {
await onSave(editData);
} finally {
setSaving(false);
}
};
const handleDelete = async () => {
if (!editData || isNew) return;
if (!confirm('삭제하시겠습니까?')) return;
setSaving(true);
try {
await onDelete(editData.idx);
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* 배경 오버레이 */}
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
{/* 다이얼로그 */}
<div className="relative bg-slate-800 rounded-xl shadow-2xl w-full max-w-lg mx-4 border border-white/10">
{/* 헤더 */}
<div className="flex items-center justify-between p-4 border-b border-white/10">
<h2 className="text-lg font-semibold text-white">
{isNew ? '품목 추가' : '품목 편집'}
</h2>
<button
onClick={onClose}
className="p-1 hover:bg-white/10 rounded text-white/70 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* 내용 */}
<div className="p-4 space-y-4 max-h-[60vh] overflow-auto">
<div className="grid grid-cols-2 gap-4">
{/* SID */}
<div>
<label className="block text-sm font-medium text-white/70 mb-1">SID</label>
<input
type="text"
value={editData.sid}
onChange={(e) => setEditData({ ...editData, sid: e.target.value })}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
/>
</div>
{/* 분류 */}
<div>
<label className="block text-sm font-medium text-white/70 mb-1"></label>
<input
type="text"
value={editData.cate}
onChange={(e) => setEditData({ ...editData, cate: e.target.value })}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
/>
</div>
</div>
{/* 품명 */}
<div>
<label className="block text-sm font-medium text-white/70 mb-1"></label>
<input
type="text"
value={editData.name}
onChange={(e) => setEditData({ ...editData, name: e.target.value })}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
/>
</div>
{/* 모델 */}
<div>
<label className="block text-sm font-medium text-white/70 mb-1"></label>
<input
type="text"
value={editData.model}
onChange={(e) => setEditData({ ...editData, model: e.target.value })}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
/>
</div>
<div className="grid grid-cols-3 gap-4">
{/* 규격 */}
<div>
<label className="block text-sm font-medium text-white/70 mb-1"></label>
<input
type="text"
value={editData.scale}
onChange={(e) => setEditData({ ...editData, scale: e.target.value })}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
/>
</div>
{/* 단위 */}
<div>
<label className="block text-sm font-medium text-white/70 mb-1"></label>
<input
type="text"
value={editData.unit}
onChange={(e) => setEditData({ ...editData, unit: e.target.value })}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
/>
</div>
{/* 단가 */}
<div>
<label className="block text-sm font-medium text-white/70 mb-1"></label>
<input
type="number"
value={editData.price}
onChange={(e) => setEditData({ ...editData, price: parseFloat(e.target.value) || 0 })}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white text-right"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
{/* 공급처 */}
<div>
<label className="block text-sm font-medium text-white/70 mb-1"></label>
<input
type="text"
value={editData.supply}
onChange={(e) => setEditData({ ...editData, supply: e.target.value })}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
/>
</div>
{/* 제조사 */}
<div>
<label className="block text-sm font-medium text-white/70 mb-1"></label>
<input
type="text"
value={editData.manu}
onChange={(e) => setEditData({ ...editData, manu: e.target.value })}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
/>
</div>
</div>
{/* 보관장소 */}
<div>
<label className="block text-sm font-medium text-white/70 mb-1"></label>
<input
type="text"
value={editData.storage}
onChange={(e) => setEditData({ ...editData, storage: e.target.value })}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
/>
</div>
{/* 메모 */}
<div>
<label className="block text-sm font-medium text-white/70 mb-1"></label>
<textarea
value={editData.memo}
onChange={(e) => setEditData({ ...editData, memo: e.target.value })}
rows={2}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white resize-none"
/>
</div>
{/* 비활성화 */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="disable"
checked={editData.disable}
onChange={(e) => setEditData({ ...editData, disable: e.target.checked })}
className="w-4 h-4 rounded border-white/20 bg-white/10"
/>
<label htmlFor="disable" className="text-sm text-white/70"></label>
</div>
</div>
{/* 푸터 */}
<div className="flex items-center justify-between p-4 border-t border-white/10">
<div>
{!isNew && (
<button
onClick={handleDelete}
disabled={saving}
className="flex items-center gap-1 px-3 py-2 bg-red-600/20 hover:bg-red-600/40 rounded-lg text-red-400 transition-colors disabled:opacity-50"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
<div className="flex items-center gap-2">
<button
onClick={onClose}
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white transition-colors"
>
</button>
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white transition-colors disabled:opacity-50"
>
<Save className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1 @@
export { ItemEditDialog } from './ItemEditDialog';

View File

@@ -0,0 +1,336 @@
import { useState, useEffect, useMemo } from 'react';
import { X, ChevronRight, ChevronDown, Search } from 'lucide-react';
import { createPortal } from 'react-dom';
import { comms } from '@/communication';
import { JobTypeItem } from '@/types';
interface JobTypeSelectModalProps {
isOpen: boolean;
currentProcess?: string;
currentJobgrp?: string;
currentType?: string;
onClose: () => void;
onSelect: (process: string, jobgrp: string, type: string) => void;
}
interface TreeNode {
name: string;
children?: TreeNode[];
item?: JobTypeItem;
}
export function JobTypeSelectModal({
isOpen,
currentProcess,
currentJobgrp,
currentType,
onClose,
onSelect,
}: JobTypeSelectModalProps) {
const [jobTypes, setJobTypes] = useState<JobTypeItem[]>([]);
const [loading, setLoading] = useState(false);
const [searchKey, setSearchKey] = useState('');
const [expandedNodes, setExpandedNodes] = useState<Set<string>>(new Set());
const [selectedPath, setSelectedPath] = useState<string>('');
// 데이터 로드
useEffect(() => {
if (!isOpen) return;
const loadJobTypes = async () => {
setLoading(true);
try {
const response = await comms.getJobTypes('');
if (response.Success && response.Data) {
setJobTypes(response.Data);
// 모든 노드 확장
const allExpanded = new Set<string>();
response.Data.forEach((item) => {
const process = item.process || 'N/A';
const jobgrp = item.jobgrp || 'N/A';
allExpanded.add(process);
allExpanded.add(`${process}|${jobgrp}`);
});
setExpandedNodes(allExpanded);
// 현재 선택된 항목이 있으면 선택 표시
if (currentType) {
setSelectedPath(`${currentProcess || 'N/A'}|${currentJobgrp || 'N/A'}|${currentType}`);
}
}
} catch (error) {
console.error('업무형태 로드 오류:', error);
} finally {
setLoading(false);
}
};
loadJobTypes();
}, [isOpen, currentProcess, currentJobgrp, currentType]);
// 트리 구조 생성
const treeData = useMemo(() => {
const processMap = new Map<string, Map<string, JobTypeItem[]>>();
// 검색 필터 적용
const filteredTypes = searchKey
? jobTypes.filter(
(item) =>
item.type?.toLowerCase().includes(searchKey.toLowerCase()) ||
item.jobgrp?.toLowerCase().includes(searchKey.toLowerCase()) ||
item.process?.toLowerCase().includes(searchKey.toLowerCase())
)
: jobTypes;
// 그룹핑
filteredTypes.forEach((item) => {
const process = item.process || 'N/A';
const jobgrp = item.jobgrp || 'N/A';
if (!processMap.has(process)) {
processMap.set(process, new Map());
}
const grpMap = processMap.get(process)!;
if (!grpMap.has(jobgrp)) {
grpMap.set(jobgrp, []);
}
grpMap.get(jobgrp)!.push(item);
});
// 트리 노드 생성
const tree: TreeNode[] = [];
processMap.forEach((grpMap, processName) => {
const processNode: TreeNode = {
name: processName,
children: [],
};
grpMap.forEach((items, grpName) => {
const grpNode: TreeNode = {
name: grpName,
children: items.map((item) => ({
name: item.type,
item,
})),
};
processNode.children!.push(grpNode);
});
tree.push(processNode);
});
// 정렬
tree.sort((a, b) => a.name.localeCompare(b.name));
tree.forEach((p) => {
p.children?.sort((a, b) => a.name.localeCompare(b.name));
p.children?.forEach((g) => {
g.children?.sort((a, b) => a.name.localeCompare(b.name));
});
});
return tree;
}, [jobTypes, searchKey]);
// 검색 시 모든 노드 확장
useEffect(() => {
if (searchKey && treeData.length > 0) {
const allExpanded = new Set<string>();
treeData.forEach((processNode) => {
allExpanded.add(processNode.name);
processNode.children?.forEach((grpNode) => {
allExpanded.add(`${processNode.name}|${grpNode.name}`);
});
});
setExpandedNodes(allExpanded);
}
}, [searchKey, treeData]);
// 노드 토글
const toggleNode = (path: string) => {
setExpandedNodes((prev) => {
const newSet = new Set(prev);
if (newSet.has(path)) {
newSet.delete(path);
} else {
newSet.add(path);
}
return newSet;
});
};
// 항목 더블클릭
const handleDoubleClick = (item: JobTypeItem) => {
const process = item.process || 'N/A';
const jobgrp = item.jobgrp || 'N/A';
onSelect(process, jobgrp, item.type);
onClose();
};
// 선택 버튼 클릭
const handleSelectClick = () => {
if (selectedPath) {
const parts = selectedPath.split('|');
if (parts.length === 3) {
onSelect(parts[0], parts[1], parts[2]);
onClose();
}
}
};
if (!isOpen) return null;
return createPortal(
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-[60]"
onClick={onClose}
>
<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 max-h-[85vh] flex flex-col"
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"> </h2>
<button
onClick={onClose}
className="text-white/70 hover:text-white transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
{/* 검색 */}
<div className="px-6 py-3 border-b border-white/10">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-white/50" />
<input
type="text"
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
placeholder="검색어를 입력하세요..."
className="w-full bg-white/10 border border-white/20 rounded-lg pl-10 pr-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
autoFocus
/>
</div>
</div>
{/* 트리뷰 */}
<div className="flex-1 overflow-y-auto p-4">
{loading ? (
<div className="text-white/50 text-center py-8"> ...</div>
) : treeData.length === 0 ? (
<div className="text-white/50 text-center py-8">
{searchKey ? '검색 결과가 없습니다' : '업무형태가 없습니다'}
</div>
) : (
<div className="space-y-1">
{treeData.map((processNode) => (
<div key={processNode.name} className="border border-white/10 rounded-lg overflow-hidden mb-2">
{/* 공정 레벨 */}
<button
className="flex items-center w-full px-3 py-2 text-left bg-white/5 hover:bg-white/10 transition-colors"
onClick={() => toggleNode(processNode.name)}
>
{expandedNodes.has(processNode.name) ? (
<ChevronDown className="w-4 h-4 mr-2 text-primary-400" />
) : (
<ChevronRight className="w-4 h-4 mr-2 text-white/50" />
)}
<span className="font-semibold text-primary-300">{processNode.name}</span>
<span className="ml-2 text-xs text-white/40">
({processNode.children?.reduce((acc, g) => acc + (g.children?.length || 0), 0) || 0})
</span>
</button>
{/* 업무분류 레벨 */}
{expandedNodes.has(processNode.name) && processNode.children && (
<div className="border-t border-white/10">
{processNode.children.map((grpNode) => {
const grpPath = `${processNode.name}|${grpNode.name}`;
return (
<div key={grpPath} className="border-b border-white/5 last:border-b-0">
<button
className="flex items-center w-full px-4 py-1.5 text-left bg-white/5 hover:bg-white/10 transition-colors"
onClick={() => toggleNode(grpPath)}
>
{expandedNodes.has(grpPath) ? (
<ChevronDown className="w-3 h-3 mr-2 text-white/50" />
) : (
<ChevronRight className="w-3 h-3 mr-2 text-white/40" />
)}
<span className="text-white/80">{grpNode.name}</span>
<span className="ml-2 text-xs text-white/40">
({grpNode.children?.length || 0})
</span>
</button>
{/* 업무형태 레벨 */}
{expandedNodes.has(grpPath) && grpNode.children && (
<div className="bg-black/20 py-1">
{grpNode.children.map((typeNode) => {
const typePath = `${grpPath}|${typeNode.name}`;
const isSelected = selectedPath === typePath;
return (
<button
key={typePath}
className={`w-full px-8 py-1.5 text-left transition-colors ${
isSelected
? 'bg-primary-500/40 text-primary-200'
: 'text-white/70 hover:bg-white/10 hover:text-white'
}`}
onClick={() => setSelectedPath(typePath)}
onDoubleClick={() => typeNode.item && handleDoubleClick(typeNode.item)}
>
{typeNode.name}
</button>
);
})}
</div>
)}
</div>
);
})}
</div>
)}
</div>
))}
</div>
)}
</div>
{/* 선택된 항목 표시 */}
{selectedPath && (
<div className="px-6 py-3 border-t border-white/10 bg-primary-500/10">
<div className="text-sm">
<span className="text-white/50">: </span>
<span className="text-primary-300 font-medium">
{selectedPath.split('|').reverse().join(' ← ')}
</span>
</div>
</div>
)}
{/* 푸터 */}
<div className="px-6 py-4 border-t border-white/10 flex justify-end space-x-3">
<button
onClick={onClose}
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors"
>
</button>
<button
onClick={handleSelectClick}
disabled={!selectedPath}
className="bg-primary-500 hover:bg-primary-600 text-white px-6 py-2 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
</button>
</div>
</div>
</div>
</div>,
document.body
);
}

View File

@@ -0,0 +1,372 @@
import { useState } from 'react';
import { FileText, Plus, Trash2, X, Loader2, ChevronDown } from 'lucide-react';
import { createPortal } from 'react-dom';
import { JobReportItem } from '@/types';
import { JobTypeSelectModal } from './JobTypeSelectModal';
export interface JobreportFormData {
pdate: string;
projectName: string;
requestpart: string;
package: string;
type: string;
process: string;
status: string;
description: string;
hrs: number;
ot: number;
jobgrp: string;
tag: string;
}
// 날짜 포맷 헬퍼 함수 (로컬 시간 기준)
const formatDateLocal = (date: Date) => {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
};
export const initialFormData: JobreportFormData = {
pdate: formatDateLocal(new Date()),
projectName: '',
requestpart: '',
package: '',
type: '',
process: '',
status: '진행 완료',
description: '',
hrs: 8,
ot: 0,
jobgrp: '',
tag: '',
};
interface JobreportEditModalProps {
isOpen: boolean;
editingItem: JobReportItem | null;
formData: JobreportFormData;
processing: boolean;
onClose: () => void;
onFormChange: (data: JobreportFormData) => void;
onSave: () => void;
onDelete: (idx: number) => void;
}
export function JobreportEditModal({
isOpen,
editingItem,
formData,
processing,
onClose,
onFormChange,
onSave,
onDelete,
}: JobreportEditModalProps) {
const [showJobTypeModal, setShowJobTypeModal] = useState(false);
if (!isOpen) return null;
const handleFieldChange = <K extends keyof JobreportFormData>(
field: K,
value: JobreportFormData[K]
) => {
onFormChange({ ...formData, [field]: value });
};
// 업무형태 선택 처리
const handleJobTypeSelect = (process: string, jobgrp: string, type: string) => {
onFormChange({
...formData,
process,
jobgrp,
type,
});
};
// 업무형태 표시 텍스트
const getJobTypeDisplayText = () => {
if (!formData.type) {
return '업무형태를 선택하세요';
}
if (formData.jobgrp && formData.jobgrp !== 'N/A') {
return `${formData.type}${formData.jobgrp}`;
}
return formData.type;
};
// 유효성 검사
const handleSaveWithValidation = () => {
// 프로젝트명 필수
if (!formData.projectName.trim()) {
alert('프로젝트(아이템) 명칭이 없습니다.');
return;
}
// 업무형태가 '휴가'가 아니면 업무내용 필수
if (formData.type !== '휴가' && !formData.description.trim()) {
alert('진행 내용이 없습니다.');
return;
}
// 근무시간 + 초과시간이 0이면 등록 불가
const totalHours = (formData.hrs || 0) + (formData.ot || 0);
if (totalHours === 0) {
alert('근무시간/초과시간이 입력되지 않았습니다.');
return;
}
// 상태 필수
if (!formData.status.trim()) {
alert('상태를 선택하세요.');
return;
}
// 업무형태 필수
if (!formData.type.trim()) {
alert('업무형태를 선택하세요.');
return;
}
// 공정 필수
if (!formData.process.trim()) {
alert('업무프로세스를 선택하세요.');
return;
}
// 유효성 검사 통과, 저장 진행
onSave();
};
return createPortal(
<div
className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50"
onClick={onClose}
>
<div className="flex items-center justify-center min-h-screen p-4">
<div
className="glass-effect rounded-2xl w-full max-w-3xl animate-slide-up max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
{/* 헤더 */}
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between sticky top-0 bg-slate-800/95 backdrop-blur z-10">
<h2 className="text-xl font-semibold text-white flex items-center">
<FileText className="w-5 h-5 mr-2" />
{editingItem ? '업무일지 수정' : '업무일지 등록'}
</h2>
<button
onClick={onClose}
className="text-white/70 hover:text-white transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
{/* 내용 */}
<div className="p-6 space-y-4">
{/* 1행: 날짜, 프로젝트명 */}
<div className="grid grid-cols-4 gap-4">
<div>
<label className="block text-white/70 text-sm font-medium mb-2">
*
</label>
<input
type="date"
value={formData.pdate}
onChange={(e) => handleFieldChange('pdate', e.target.value)}
className="w-full bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
required
/>
</div>
<div className="col-span-3">
<label className="block text-white/70 text-sm font-medium mb-2">
*
</label>
<input
type="text"
value={formData.projectName}
onChange={(e) => handleFieldChange('projectName', e.target.value)}
className="w-full bg-white/20 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"
placeholder="프로젝트 또는 아이템명"
required
/>
</div>
</div>
{/* 2행: 요청부서, 패키지 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-white/70 text-sm font-medium mb-2">
</label>
<input
type="text"
value={formData.requestpart}
onChange={(e) => handleFieldChange('requestpart', e.target.value)}
className="w-full bg-white/20 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"
placeholder="요청부서"
/>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">
</label>
<input
type="text"
value={formData.package}
onChange={(e) => handleFieldChange('package', e.target.value)}
className="w-full bg-white/20 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"
placeholder="패키지"
/>
</div>
</div>
{/* 3행: 업무형태 선택 버튼 */}
<div>
<label className="block text-white/70 text-sm font-medium mb-2">
*
</label>
<button
type="button"
onClick={() => setShowJobTypeModal(true)}
className={`w-full border rounded-lg px-4 py-2 text-left flex items-center justify-between focus:outline-none focus:ring-2 focus:ring-primary-400 transition-colors ${
formData.type
? 'bg-white/20 border-white/30 text-white'
: 'bg-pink-500/30 border-pink-400/50 text-pink-200'
}`}
>
<span>{getJobTypeDisplayText()}</span>
<ChevronDown className="w-4 h-4 text-white/50" />
</button>
{formData.process && (
<div className="mt-1 text-xs text-white/50">
: {formData.process}
</div>
)}
</div>
{/* 4행: 상태, 근무시간, 초과시간 */}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-white/70 text-sm font-medium mb-2">
</label>
<select
value={formData.status}
onChange={(e) => handleFieldChange('status', e.target.value)}
className="w-full bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
>
<option value="진행 완료" className="bg-gray-800">
</option>
<option value="진행 중" className="bg-gray-800">
</option>
<option value="대기" className="bg-gray-800">
</option>
</select>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">
(h)
</label>
<input
type="number"
step="0.5"
min="0"
max="24"
value={formData.hrs}
onChange={(e) =>
handleFieldChange('hrs', parseFloat(e.target.value) || 0)
}
className="w-full bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">
(h)
</label>
<input
type="number"
step="0.5"
min="0"
max="24"
value={formData.ot}
onChange={(e) =>
handleFieldChange('ot', parseFloat(e.target.value) || 0)
}
className="w-full bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
</div>
</div>
{/* 업무내용 */}
<div>
<label className="block text-white/70 text-sm font-medium mb-2">
</label>
<textarea
value={formData.description}
onChange={(e) => handleFieldChange('description', e.target.value)}
rows={6}
className="w-full bg-white/20 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 resize-none"
placeholder="업무 내용을 입력하세요"
/>
</div>
</div>
{/* 푸터 */}
<div className="px-6 py-4 border-t border-white/10 flex justify-between sticky bottom-0 bg-slate-800/95 backdrop-blur">
{/* 좌측: 삭제 버튼 (편집 모드일 때만) */}
<div>
{editingItem && (
<button
onClick={() => {
if (editingItem) {
onDelete(editingItem.idx);
}
}}
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
onClick={onClose}
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors"
>
</button>
<button
onClick={handleSaveWithValidation}
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" />
)}
{editingItem ? '수정' : '등록'}
</button>
</div>
</div>
</div>
</div>
{/* 업무형태 선택 모달 */}
<JobTypeSelectModal
isOpen={showJobTypeModal}
currentProcess={formData.process}
currentJobgrp={formData.jobgrp}
currentType={formData.type}
onClose={() => setShowJobTypeModal(false)}
onSelect={handleJobTypeSelect}
/>
</div>,
document.body
);
}

View File

@@ -0,0 +1,22 @@
interface AmkorLogoProps {
className?: string;
height?: number;
}
export function AmkorLogo({ className = '', height = 48 }: AmkorLogoProps) {
return (
<svg
viewBox="0 0 50 50"
height={height}
className={className}
>
{/* 흰색 원 배경 */}
<circle cx="25" cy="25" r="23" fill="white" />
{/* 파란색 A */}
<path
d="M25 8 L38 40 L32 40 L29 32 L16 32 L16 26 L27 26 L22.5 14 L17 28 L11 40 L5 40 L18 8 Z"
fill="#1a5091"
/>
</svg>
);
}

View File

@@ -0,0 +1,450 @@
import { useState, useRef, useEffect } from 'react';
import { NavLink, useNavigate } from 'react-router-dom';
import {
CheckSquare,
Clock as ClockIcon,
FileText,
FolderKanban,
Code,
Menu,
X,
ChevronDown,
ChevronRight,
Database,
Package,
User,
Users,
CalendarDays,
Mail,
Shield,
} from 'lucide-react';
import { clsx } from 'clsx';
import { UserInfoDialog } from '@/components/user/UserInfoDialog';
import { AmkorLogo } from './AmkorLogo';
interface HeaderProps {
isConnected: boolean;
}
interface NavItem {
path?: string;
icon: React.ElementType;
label: string;
action?: string;
}
interface SubMenu {
label: string;
icon: React.ElementType;
items: NavItem[];
}
interface MenuItem {
type: 'link' | 'submenu' | 'action';
path?: string;
icon: React.ElementType;
label: string;
submenu?: SubMenu;
action?: string;
}
interface DropdownMenuConfig {
label: string;
icon: React.ElementType;
items: MenuItem[];
}
// 일반 메뉴 항목
const navItems: NavItem[] = [
{ path: '/jobreport', icon: FileText, label: '업무일지' },
{ path: '/project', icon: FolderKanban, label: '프로젝트' },
{ path: '/todo', icon: CheckSquare, label: '할일' },
{ path: '/kuntae', icon: ClockIcon, label: '근태' },
];
// 드롭다운 메뉴 (2단계 지원)
const dropdownMenus: DropdownMenuConfig[] = [
{
label: '공용정보',
icon: Database,
items: [
{ type: 'link', path: '/common', icon: Code, label: '공용코드' },
{ type: 'link', path: '/items', icon: Package, label: '품목정보' },
{
type: 'submenu',
icon: Users,
label: '사용자',
submenu: {
label: '사용자',
icon: Users,
items: [
{ icon: User, label: '정보', action: 'userInfo' },
{ path: '/user/list', icon: Users, label: '목록' },
],
},
},
{ type: 'link', path: '/monthly-work', icon: CalendarDays, label: '월별근무표' },
{ type: 'link', path: '/mail-form', icon: Mail, label: '메일양식' },
{ type: 'link', path: '/user-group', icon: Shield, label: '그룹정보' },
],
},
];
function DropdownNavMenu({
menu,
onItemClick,
onAction
}: {
menu: DropdownMenuConfig;
onItemClick?: () => void;
onAction?: (action: string) => void;
}) {
const [isOpen, setIsOpen] = useState(false);
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) {
setIsOpen(false);
setActiveSubmenu(null);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSubItemClick = (subItem: NavItem) => {
setIsOpen(false);
setActiveSubmenu(null);
if (subItem.action) {
onAction?.(subItem.action);
}
onItemClick?.();
};
return (
<div className="relative" ref={dropdownRef}>
<button
onClick={() => setIsOpen(!isOpen)}
className={clsx(
'flex items-center space-x-2 px-4 py-2 rounded-lg transition-all duration-200 text-sm font-medium',
isOpen
? 'bg-white/20 text-white'
: 'text-white/70 hover:bg-white/10 hover:text-white'
)}
>
<menu.icon className="w-4 h-4" />
<span>{menu.label}</span>
<ChevronDown className={clsx('w-3 h-3 transition-transform', isOpen && 'rotate-180')} />
</button>
{isOpen && (
<div className="absolute top-full left-0 mt-1 min-w-[160px] glass-effect-solid rounded-lg py-1 z-[9999]">
{menu.items.map((item) => (
item.type === 'link' ? (
<NavLink
key={item.path}
to={item.path!}
onClick={() => {
setIsOpen(false);
onItemClick?.();
}}
className={({ isActive }) =>
clsx(
'flex items-center space-x-2 px-4 py-2 text-sm transition-colors',
isActive
? 'bg-white/20 text-white'
: 'text-white/70 hover:bg-white/10 hover:text-white'
)
}
>
<item.icon className="w-4 h-4" />
<span>{item.label}</span>
</NavLink>
) : (
<div
key={item.label}
className="relative"
onMouseEnter={() => setActiveSubmenu(item.label)}
onMouseLeave={() => setActiveSubmenu(null)}
>
<div
className={clsx(
'flex items-center justify-between px-4 py-2 text-sm transition-colors cursor-pointer',
activeSubmenu === item.label
? 'bg-white/20 text-white'
: 'text-white/70 hover:bg-white/10 hover:text-white'
)}
>
<div className="flex items-center space-x-2">
<item.icon className="w-4 h-4" />
<span>{item.label}</span>
</div>
<ChevronRight className="w-3 h-3" />
</div>
{activeSubmenu === item.label && item.submenu && (
<div className="absolute left-full top-0 ml-1 min-w-[120px] glass-effect-solid rounded-lg py-1 z-[10000]">
{item.submenu.items.map((subItem) => (
subItem.path ? (
<NavLink
key={subItem.path}
to={subItem.path}
onClick={() => handleSubItemClick(subItem)}
className={({ isActive }) =>
clsx(
'flex items-center space-x-2 px-4 py-2 text-sm transition-colors',
isActive
? 'bg-white/20 text-white'
: 'text-white/70 hover:bg-white/10 hover:text-white'
)
}
>
<subItem.icon className="w-4 h-4" />
<span>{subItem.label}</span>
</NavLink>
) : (
<button
key={subItem.label}
onClick={() => handleSubItemClick(subItem)}
className="flex items-center space-x-2 px-4 py-2 text-sm transition-colors text-white/70 hover:bg-white/10 hover:text-white w-full text-left"
>
<subItem.icon className="w-4 h-4" />
<span>{subItem.label}</span>
</button>
)
))}
</div>
)}
</div>
)
))}
</div>
)}
</div>
);
}
// 모바일용 드롭다운 (펼쳐진 형태)
function MobileDropdownMenu({
menu,
onItemClick,
onAction
}: {
menu: DropdownMenuConfig;
onItemClick?: () => void;
onAction?: (action: string) => void;
}) {
const [isOpen, setIsOpen] = useState(false);
const [activeSubmenu, setActiveSubmenu] = useState<string | null>(null);
const handleSubItemClick = (subItem: NavItem) => {
if (subItem.action) {
onAction?.(subItem.action);
}
onItemClick?.();
};
return (
<div>
<button
onClick={() => setIsOpen(!isOpen)}
className="flex items-center justify-between w-full px-4 py-3 rounded-lg text-white/70 hover:bg-white/10 hover:text-white transition-all duration-200"
>
<div className="flex items-center space-x-3">
<menu.icon className="w-5 h-5" />
<span className="font-medium">{menu.label}</span>
</div>
<ChevronDown className={clsx('w-4 h-4 transition-transform', isOpen && 'rotate-180')} />
</button>
{isOpen && (
<div className="ml-6 mt-1 space-y-1">
{menu.items.map((item) => (
item.type === 'link' ? (
<NavLink
key={item.path}
to={item.path!}
onClick={onItemClick}
className={({ isActive }) =>
clsx(
'flex items-center space-x-3 px-4 py-2 rounded-lg transition-all duration-200',
isActive
? 'bg-white/20 text-white'
: 'text-white/70 hover:bg-white/10 hover:text-white'
)
}
>
<item.icon className="w-4 h-4" />
<span>{item.label}</span>
</NavLink>
) : (
<div key={item.label}>
<button
onClick={() => setActiveSubmenu(activeSubmenu === item.label ? null : item.label)}
className="flex items-center justify-between w-full px-4 py-2 rounded-lg text-white/70 hover:bg-white/10 hover:text-white transition-all duration-200"
>
<div className="flex items-center space-x-3">
<item.icon className="w-4 h-4" />
<span>{item.label}</span>
</div>
<ChevronDown className={clsx('w-3 h-3 transition-transform', activeSubmenu === item.label && 'rotate-180')} />
</button>
{activeSubmenu === item.label && item.submenu && (
<div className="ml-6 mt-1 space-y-1">
{item.submenu.items.map((subItem) => (
subItem.path ? (
<NavLink
key={subItem.path}
to={subItem.path}
onClick={onItemClick}
className={({ isActive }) =>
clsx(
'flex items-center space-x-3 px-4 py-2 rounded-lg transition-all duration-200',
isActive
? 'bg-white/20 text-white'
: 'text-white/70 hover:bg-white/10 hover:text-white'
)
}
>
<subItem.icon className="w-4 h-4" />
<span>{subItem.label}</span>
</NavLink>
) : (
<button
key={subItem.label}
onClick={() => handleSubItemClick(subItem)}
className="flex items-center space-x-3 px-4 py-2 rounded-lg transition-all duration-200 text-white/70 hover:bg-white/10 hover:text-white w-full text-left"
>
<subItem.icon className="w-4 h-4" />
<span>{subItem.label}</span>
</button>
)
))}
</div>
)}
</div>
)
))}
</div>
)}
</div>
);
}
export function Header({ isConnected }: HeaderProps) {
const navigate = useNavigate();
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const [showUserInfoDialog, setShowUserInfoDialog] = useState(false);
const handleAction = (action: string) => {
if (action === 'userInfo') {
setShowUserInfoDialog(true);
}
};
return (
<>
<header className="glass-effect relative z-[9999]">
{/* Main Header Bar */}
<div className="px-4 py-3 flex items-center justify-between">
{/* Logo & Mobile Menu Button */}
<div className="flex items-center space-x-4">
<button
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
className="lg:hidden text-white/80 hover:text-white transition-colors"
>
{isMobileMenuOpen ? <X className="w-6 h-6" /> : <Menu className="w-6 h-6" />}
</button>
<div
className="cursor-pointer hover:opacity-80 transition-opacity"
onClick={() => navigate('/')}
>
<AmkorLogo height={36} />
</div>
</div>
{/* Desktop Navigation */}
<nav className="hidden lg:flex items-center space-x-1">
{/* 드롭다운 메뉴들 */}
{dropdownMenus.map((menu) => (
<DropdownNavMenu key={menu.label} menu={menu} onAction={handleAction} />
))}
{/* 일반 메뉴들 */}
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path!}
className={({ isActive }) =>
clsx(
'flex items-center space-x-2 px-4 py-2 rounded-lg transition-all duration-200 text-sm font-medium',
isActive
? 'bg-white/20 text-white shadow-lg'
: 'text-white/70 hover:bg-white/10 hover:text-white'
)
}
>
<item.icon className="w-4 h-4" />
<span>{item.label}</span>
</NavLink>
))}
</nav>
{/* Right Section: Connection Status (Icon only) */}
<div
className={`w-2.5 h-2.5 rounded-full ${
isConnected ? 'bg-success-400 animate-pulse' : 'bg-danger-400'
}`}
title={isConnected ? '연결됨' : '연결 끊김'}
/>
</div>
{/* Mobile Navigation Dropdown */}
{isMobileMenuOpen && (
<div className="lg:hidden border-t border-white/10">
<nav className="px-4 py-2 space-y-1">
{/* 드롭다운 메뉴들 */}
{dropdownMenus.map((menu) => (
<MobileDropdownMenu
key={menu.label}
menu={menu}
onItemClick={() => setIsMobileMenuOpen(false)}
onAction={handleAction}
/>
))}
{/* 일반 메뉴들 */}
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path!}
onClick={() => setIsMobileMenuOpen(false)}
className={({ isActive }) =>
clsx(
'flex items-center space-x-3 px-4 py-3 rounded-lg transition-all duration-200',
isActive
? 'bg-white/20 text-white'
: 'text-white/70 hover:bg-white/10 hover:text-white'
)
}
>
<item.icon className="w-5 h-5" />
<span className="font-medium">{item.label}</span>
</NavLink>
))}
</nav>
</div>
)}
</header>
{/* User Info Dialog */}
<UserInfoDialog
isOpen={showUserInfoDialog}
onClose={() => setShowUserInfoDialog(false)}
/>
</>
);
}

View File

@@ -0,0 +1,28 @@
import { Outlet } from 'react-router-dom';
import { Header } from './Header';
import { StatusBar } from './StatusBar';
import { UserInfo } from '@/types';
interface LayoutProps {
isConnected: boolean;
user?: UserInfo | null;
}
export function Layout({ isConnected, user }: LayoutProps) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900">
<div className="flex flex-col h-screen overflow-hidden">
{/* Top Navigation Header */}
<Header isConnected={isConnected} />
{/* Page Content */}
<main className="flex-1 overflow-y-auto p-6 custom-scrollbar">
<Outlet />
</main>
{/* Bottom Status Bar */}
<StatusBar userName={user?.Name} userDept={user?.Dept} isConnected={isConnected} />
</div>
</div>
);
}

View File

@@ -0,0 +1,79 @@
import { useState, useEffect } from 'react';
import { Clock, Wifi, WifiOff } from 'lucide-react';
import { UserInfoButton } from './UserInfoButton';
import { comms } from '@/communication';
interface StatusBarProps {
userName?: string;
userDept?: string;
isConnected?: boolean;
}
export function StatusBar({ userName, userDept, isConnected }: StatusBarProps) {
const [currentTime, setCurrentTime] = useState(new Date());
const [versionDisplay, setVersionDisplay] = useState('');
useEffect(() => {
const timer = setInterval(() => setCurrentTime(new Date()), 1000);
return () => clearInterval(timer);
}, []);
// 앱 버전 로드
useEffect(() => {
const loadVersion = async () => {
try {
const result = await comms.getAppVersion();
if (result.Success) {
setVersionDisplay(result.DisplayVersion);
}
} catch (error) {
console.error('버전 정보 로드 오류:', error);
}
};
loadVersion();
}, []);
return (
<footer className="glass-effect px-4 py-2 flex items-center justify-between text-sm">
{/* Left: User Info */}
<div className="flex items-center space-x-2">
<UserInfoButton userName={userName} userDept={userDept} />
</div>
{/* Center: App Version */}
<div className="text-white/50">
{versionDisplay || 'Loading...'}
</div>
{/* Right: Connection Status & Time */}
<div className="flex items-center space-x-4 text-white/70">
{/* Connection Status */}
<div className="flex items-center space-x-1">
{isConnected ? (
<Wifi className="w-4 h-4 text-success-400" />
) : (
<WifiOff className="w-4 h-4 text-danger-400" />
)}
<span className={isConnected ? 'text-success-400' : 'text-danger-400'}>
{isConnected ? '연결됨' : '연결 끊김'}
</span>
</div>
{/* Current Time */}
<div className="flex items-center space-x-2">
<Clock className="w-4 h-4" />
<span>
{currentTime.toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})}
</span>
</div>
</div>
</footer>
);
}

View File

@@ -0,0 +1,107 @@
import { useState } from 'react';
import { createPortal } from 'react-dom';
import { User, LogOut, X } from 'lucide-react';
import { comms } from '@/communication';
interface UserInfoButtonProps {
userName?: string;
userDept?: string;
}
export function UserInfoButton({ userName, userDept }: UserInfoButtonProps) {
const [showLogoutDialog, setShowLogoutDialog] = useState(false);
const [processing, setProcessing] = useState(false);
const handleLogout = async () => {
setProcessing(true);
try {
const result = await comms.logout();
if (result.Success) {
// 로그아웃 성공 - 페이지 새로고침으로 로그인 화면 표시
window.location.reload();
} else {
alert(result.Message || '로그아웃에 실패했습니다.');
}
} catch (error) {
console.error('로그아웃 오류:', error);
alert('서버 연결에 실패했습니다.');
} finally {
setProcessing(false);
setShowLogoutDialog(false);
}
};
if (!userName) return null;
return (
<>
{/* 사용자 정보 버튼 */}
<button
onClick={() => setShowLogoutDialog(true)}
className="flex items-center space-x-2 text-white/70 hover:text-white transition-colors cursor-pointer"
>
<User className="w-4 h-4" />
<span>{userName}</span>
{userDept && <span className="text-white/50">({userDept})</span>}
</button>
{/* 로그아웃 다이얼로그 - Portal로 body에 직접 렌더링 */}
{showLogoutDialog && createPortal(
<div
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/50 backdrop-blur-sm"
onClick={() => setShowLogoutDialog(false)}
>
<div
className="glass-effect rounded-2xl w-full max-w-sm animate-slide-up mx-4"
onClick={(e) => e.stopPropagation()}
>
{/* 헤더 */}
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white flex items-center">
<LogOut className="w-5 h-5 mr-2" />
</h2>
<button
onClick={() => setShowLogoutDialog(false)}
className="text-white/70 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* 내용 */}
<div className="p-6">
<div className="text-center mb-6">
<div className="w-16 h-16 bg-white/10 rounded-full flex items-center justify-center mx-auto mb-4">
<User className="w-8 h-8 text-white/70" />
</div>
<p className="text-white font-medium">{userName}</p>
{userDept && <p className="text-white/50 text-sm">{userDept}</p>}
</div>
<p className="text-white/70 text-center text-sm">
?
</p>
</div>
{/* 푸터 */}
<div className="px-6 py-4 border-t border-white/10 flex justify-center">
<button
onClick={handleLogout}
disabled={processing}
className="bg-danger-500 hover:bg-danger-600 text-white px-6 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50"
>
{processing ? (
<span className="animate-spin mr-2"></span>
) : (
<LogOut className="w-4 h-4 mr-2" />
)}
</button>
</div>
</div>
</div>,
document.body
)}
</>
);
}

View File

@@ -0,0 +1,3 @@
export { Layout } from './Layout';
export { Header } from './Header';
export { StatusBar } from './StatusBar';

View File

@@ -0,0 +1,449 @@
import { useState, useEffect } from 'react';
import { X, Save, Key, User, Mail, Building2, Briefcase, Calendar, FileText } from 'lucide-react';
import { clsx } from 'clsx';
import { comms } from '@/communication';
import { UserInfoDetail } from '@/types';
interface UserInfoDialogProps {
isOpen: boolean;
onClose: () => void;
userId?: string;
onSave?: () => void;
}
interface PasswordDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (oldPassword: string, newPassword: string) => void;
}
function PasswordDialog({ isOpen, onClose, onConfirm }: PasswordDialogProps) {
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const handleSubmit = () => {
if (!newPassword) {
setError('새 비밀번호를 입력하세요.');
return;
}
if (newPassword !== confirmPassword) {
setError('새 비밀번호가 일치하지 않습니다.');
return;
}
onConfirm(oldPassword, newPassword);
setOldPassword('');
setNewPassword('');
setConfirmPassword('');
setError('');
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[10001]">
<div className="glass-effect-solid rounded-xl p-6 w-full max-w-sm">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Key className="w-5 h-5" />
</h3>
<button onClick={onClose} className="text-white/60 hover:text-white">
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm text-white/70 mb-1"> </label>
<input
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40"
placeholder="기존 비밀번호"
/>
</div>
<div>
<label className="block text-sm text-white/70 mb-1"> </label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40"
placeholder="새 비밀번호"
/>
</div>
<div>
<label className="block text-sm text-white/70 mb-1"> </label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40"
placeholder="새 비밀번호 확인"
/>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
<div className="flex justify-end gap-2 mt-4">
<button
onClick={onClose}
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white transition-colors"
>
</button>
<button
onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white transition-colors"
>
</button>
</div>
</div>
</div>
</div>
);
}
export function UserInfoDialog({ isOpen, onClose, userId, onSave }: UserInfoDialogProps) {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [showPasswordDialog, setShowPasswordDialog] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const [formData, setFormData] = useState<UserInfoDetail>({
Id: '',
NameK: '',
NameE: '',
Dept: '',
Grade: '',
Email: '',
Tel: '',
Hp: '',
DateIn: '',
DateO: '',
Memo: '',
Process: '',
State: '',
UseJobReport: false,
UseUserState: false,
ExceptHoly: false,
Level: 0,
});
useEffect(() => {
if (isOpen) {
loadUserInfo();
}
}, [isOpen, userId]);
const loadUserInfo = async () => {
setLoading(true);
setMessage(null);
try {
const response = userId
? await comms.getUserInfoById(userId)
: await comms.getCurrentUserInfo();
if (response.Success && response.Data) {
setFormData(response.Data);
} else {
setMessage({ type: 'error', text: response.Message || '사용자 정보를 불러올 수 없습니다.' });
}
} catch (error) {
setMessage({ type: 'error', text: '사용자 정보 조회 중 오류가 발생했습니다.' });
} finally {
setLoading(false);
}
};
const handleSave = async () => {
setSaving(true);
setMessage(null);
try {
const response = await comms.saveUserInfo(formData);
if (response.Success) {
setMessage({ type: 'success', text: '저장되었습니다.' });
onSave?.();
setTimeout(() => {
onClose();
}, 1000);
} else {
setMessage({ type: 'error', text: response.Message || '저장에 실패했습니다.' });
}
} catch (error) {
setMessage({ type: 'error', text: '저장 중 오류가 발생했습니다.' });
} finally {
setSaving(false);
}
};
const handleChangePassword = async (oldPassword: string, newPassword: string) => {
try {
const response = await comms.changePassword(oldPassword, newPassword);
if (response.Success) {
setMessage({ type: 'success', text: '비밀번호가 변경되었습니다.' });
} else {
setMessage({ type: 'error', text: response.Message || '비밀번호 변경에 실패했습니다.' });
}
} catch (error) {
setMessage({ type: 'error', text: '비밀번호 변경 중 오류가 발생했습니다.' });
}
};
const handleInputChange = (field: keyof UserInfoDetail, value: string | boolean) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
if (!isOpen) return null;
return (
<>
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[10000]">
<div className="glass-effect-solid rounded-xl w-full max-w-2xl max-h-[90vh] overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
<h2 className="text-xl font-semibold text-white flex items-center gap-2">
<User className="w-6 h-6" />
</h2>
<button
onClick={onClose}
className="text-white/60 hover:text-white transition-colors"
>
<X className="w-6 h-6" />
</button>
</div>
{/* Content */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-140px)] custom-scrollbar">
{loading ? (
<div className="flex items-center justify-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
</div>
) : (
<div className="space-y-6">
{/* 기본 정보 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-white/70 mb-1 flex items-center gap-1">
<User className="w-4 h-4" />
</label>
<input
type="text"
value={formData.Id}
disabled
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white/50"
/>
</div>
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="text"
value={formData.NameK}
onChange={(e) => handleInputChange('NameK', e.target.value)}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40"
placeholder="이름"
/>
</div>
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="text"
value={formData.NameE}
onChange={(e) => handleInputChange('NameE', e.target.value)}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40"
placeholder="English Name"
/>
</div>
<div>
<label className="block text-sm text-white/70 mb-1 flex items-center gap-1">
<Building2 className="w-4 h-4" />
</label>
<input
type="text"
value={formData.Dept}
onChange={(e) => handleInputChange('Dept', e.target.value)}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40"
placeholder="부서"
/>
</div>
<div>
<label className="block text-sm text-white/70 mb-1 flex items-center gap-1">
<Briefcase className="w-4 h-4" />
</label>
<input
type="text"
value={formData.Grade}
onChange={(e) => handleInputChange('Grade', e.target.value)}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40"
placeholder="직책"
/>
</div>
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="text"
value={formData.Process}
onChange={(e) => handleInputChange('Process', e.target.value)}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40"
placeholder="공정"
/>
</div>
</div>
{/* 이메일 */}
<div>
<label className="block text-sm text-white/70 mb-1 flex items-center gap-1">
<Mail className="w-4 h-4" />
</label>
<input
type="email"
value={formData.Email}
onChange={(e) => handleInputChange('Email', e.target.value)}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40"
placeholder="email@example.com"
/>
</div>
{/* 입/퇴사 정보 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-white/70 mb-1 flex items-center gap-1">
<Calendar className="w-4 h-4" />
</label>
<input
type="text"
value={formData.DateIn}
onChange={(e) => handleInputChange('DateIn', e.target.value)}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40"
placeholder="YYYY-MM-DD"
/>
</div>
<div>
<label className="block text-sm text-white/70 mb-1 flex items-center gap-1">
<Calendar className="w-4 h-4" />
</label>
<input
type="text"
value={formData.DateO}
onChange={(e) => handleInputChange('DateO', e.target.value)}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40"
placeholder="YYYY-MM-DD"
/>
</div>
</div>
{/* 비고 */}
<div>
<label className="block text-sm text-white/70 mb-1 flex items-center gap-1">
<FileText className="w-4 h-4" />
</label>
<textarea
value={formData.Memo}
onChange={(e) => handleInputChange('Memo', e.target.value)}
rows={3}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40 resize-none"
placeholder="비고"
/>
</div>
{/* 옵션 체크박스 */}
<div className="flex flex-wrap gap-6">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.UseJobReport}
onChange={(e) => handleInputChange('UseJobReport', e.target.checked)}
className="w-4 h-4 rounded border-white/20 bg-white/10 text-blue-600"
/>
<span className="text-white/70"> </span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.UseUserState}
onChange={(e) => handleInputChange('UseUserState', e.target.checked)}
className="w-4 h-4 rounded border-white/20 bg-white/10 text-blue-600"
/>
<span className="text-white/70"> </span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.ExceptHoly}
onChange={(e) => handleInputChange('ExceptHoly', e.target.checked)}
className="w-4 h-4 rounded border-white/20 bg-white/10 text-blue-600"
/>
<span className="text-white/70"> </span>
</label>
</div>
{/* 메시지 */}
{message && (
<div
className={clsx(
'px-4 py-2 rounded-lg text-sm',
message.type === 'success' ? 'bg-green-600/20 text-green-400' : 'bg-red-600/20 text-red-400'
)}
>
{message.text}
</div>
)}
</div>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-white/10">
<button
onClick={() => setShowPasswordDialog(true)}
className="flex items-center gap-2 px-4 py-2 bg-yellow-600/20 hover:bg-yellow-600/30 text-yellow-400 rounded-lg transition-colors"
>
<Key className="w-4 h-4" />
</button>
<div className="flex gap-2">
<button
onClick={onClose}
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white transition-colors"
>
</button>
<button
onClick={handleSave}
disabled={saving}
className={clsx(
'flex items-center gap-2 px-4 py-2 rounded-lg transition-colors',
saving
? 'bg-blue-600/50 text-white/50 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700 text-white'
)}
>
<Save className="w-4 h-4" />
{saving ? '저장 중...' : '저장'}
</button>
</div>
</div>
</div>
</div>
<PasswordDialog
isOpen={showPasswordDialog}
onClose={() => setShowPasswordDialog(false)}
onConfirm={handleChangePassword}
/>
</>
);
}

View File

@@ -0,0 +1 @@
export { UserInfoDialog } from './UserInfoDialog';

View File

@@ -0,0 +1,62 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
.glass-effect {
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.18);
}
/* 드롭다운 메뉴용 불투명 배경 */
.glass-effect-solid {
background: rgba(30, 41, 59, 0.95);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.gradient-bg {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.card-hover {
transition: all 0.3s ease;
}
.card-hover:hover {
transform: translateY(-5px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 8px;
border: 2px solid rgba(255, 255, 255, 0.1);
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
/* 드롭다운 스타일 */
select option {
background-color: #1f2937;
color: white;
}
select:focus option:checked {
background-color: #3b82f6;
}
select option:hover {
background-color: #374151;
}

View File

@@ -0,0 +1,10 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -0,0 +1,341 @@
import { useState, useEffect } from 'react';
import { Plus, Save, Trash2, RefreshCw, FolderOpen, Edit2 } from 'lucide-react';
import { clsx } from 'clsx';
import { comms } from '@/communication';
import { CommonCodeGroup, CommonCode } from '@/types';
export function CommonCodePage() {
const [groups, setGroups] = useState<CommonCodeGroup[]>([]);
const [codes, setCodes] = useState<CommonCode[]>([]);
const [selectedGroup, setSelectedGroup] = useState<string | null>(null);
const [selectedCode, setSelectedCode] = useState<CommonCode | null>(null);
const [loading, setLoading] = useState(false);
const [isEditing, setIsEditing] = useState(false);
const [editData, setEditData] = useState<Partial<CommonCode>>({});
useEffect(() => {
loadGroups();
}, []);
useEffect(() => {
if (selectedGroup) {
loadCodes();
setSelectedCode(null);
}
}, [selectedGroup]);
const loadGroups = async () => {
try {
const result = await comms.getCommonGroups();
setGroups(result);
} catch (error) {
console.error('그룹 로드 실패:', error);
}
};
const loadCodes = async () => {
if (!selectedGroup) return;
setLoading(true);
try {
const result = await comms.getCommonList(selectedGroup);
setCodes(result);
} catch (error) {
console.error('코드 로드 실패:', error);
} finally {
setLoading(false);
}
};
const handleSave = async () => {
if (!selectedCode) return;
try {
const saveData = { ...selectedCode, ...editData } as CommonCode;
const response = await comms.saveCommon(saveData);
if (response.Success) {
setIsEditing(false);
setEditData({});
loadCodes();
} else {
alert(response.Message || '저장 실패');
}
} catch (error) {
alert('저장 중 오류가 발생했습니다.');
}
};
const handleDelete = async () => {
if (!selectedCode || selectedCode.idx === 0) return;
if (!confirm('삭제하시겠습니까?')) return;
try {
const response = await comms.deleteCommon(selectedCode.idx);
if (response.Success) {
setSelectedCode(null);
setIsEditing(false);
loadCodes();
} else {
alert(response.Message || '삭제 실패');
}
} catch (error) {
alert('삭제 중 오류가 발생했습니다.');
}
};
const handleAddNew = () => {
if (!selectedGroup) {
alert('그룹을 먼저 선택하세요.');
return;
}
const newCode: CommonCode = {
idx: 0,
grp: selectedGroup,
code: '',
svalue: '',
ivalue: 0,
fvalue: 0,
memo: '',
};
setSelectedCode(newCode);
setEditData(newCode);
setIsEditing(true);
};
const handleCancel = () => {
if (selectedCode?.idx === 0) {
setSelectedCode(null);
}
setIsEditing(false);
setEditData({});
};
const selectedGroupName = groups.find((g) => g.code === selectedGroup)?.memo || '';
return (
<div className="h-full flex gap-4">
{/* 좌측: 그룹 목록 */}
<div className="w-80 flex flex-col gap-4">
{/* 그룹 목록 */}
<div className="glass-effect rounded-xl flex-1 overflow-hidden flex flex-col">
<div className="p-3 border-b border-white/10">
<h2 className="text-sm font-semibold text-white/70"> </h2>
</div>
<div className="flex-1 overflow-auto">
{groups.map((g) => (
<button
key={g.code}
onClick={() => setSelectedGroup(g.code)}
className={clsx(
'w-full px-3 py-2 text-left text-sm flex items-center gap-2 transition-colors',
selectedGroup === g.code
? 'bg-blue-600/30 text-white border-l-2 border-blue-500'
: 'text-white/70 hover:bg-white/5 hover:text-white'
)}
>
<FolderOpen className="w-4 h-4 flex-shrink-0" />
<span className="truncate">[{g.code}] {g.memo}</span>
</button>
))}
</div>
</div>
</div>
{/* 중앙: 코드 목록 */}
<div className="w-72 flex flex-col">
<div className="glass-effect rounded-xl flex-1 overflow-hidden flex flex-col">
<div className="p-3 border-b border-white/10 flex items-center justify-between">
<div>
<h2 className="text-sm font-semibold text-white">{selectedGroupName || '그룹 선택'}</h2>
<p className="text-xs text-white/50">{codes.length}</p>
</div>
<div className="flex items-center gap-1">
<button
onClick={loadCodes}
className="p-1.5 bg-white/10 hover:bg-white/20 rounded text-white/70 hover:text-white transition-colors"
>
<RefreshCw className="w-4 h-4" />
</button>
<button
onClick={handleAddNew}
className="p-1.5 bg-blue-600 hover:bg-blue-700 rounded text-white transition-colors"
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white"></div>
</div>
) : selectedGroup ? (
codes.length > 0 ? (
codes.map((code) => (
<button
key={code.idx || 'new'}
onClick={() => {
setSelectedCode(code);
setIsEditing(false);
setEditData({});
}}
className={clsx(
'w-full px-3 py-2 text-left border-b border-white/5 transition-colors',
selectedCode?.idx === code.idx
? 'bg-blue-600/20 border-l-2 border-l-blue-500'
: 'hover:bg-white/5'
)}
>
<div className="text-sm text-white font-medium">{code.code}</div>
<div className="text-xs text-white/50 truncate">{code.memo || code.svalue}</div>
</button>
))
) : (
<div className="p-4 text-center text-white/50 text-sm">
.
</div>
)
) : (
<div className="p-4 text-center text-white/50 text-sm">
.
</div>
)}
</div>
</div>
</div>
{/* 우측: 상세 정보 */}
<div className="flex-1 glass-effect rounded-xl overflow-hidden flex flex-col">
<div className="p-4 border-b border-white/10 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white">
{selectedCode ? (isEditing ? '코드 편집' : '코드 상세') : '코드 선택'}
</h2>
{selectedCode && !isEditing && (
<div className="flex items-center gap-2">
<button
onClick={() => {
setIsEditing(true);
setEditData(selectedCode);
}}
className="flex items-center gap-1 px-3 py-1.5 bg-blue-600 hover:bg-blue-700 rounded text-white text-sm transition-colors"
>
<Edit2 className="w-3.5 h-3.5" />
</button>
<button
onClick={handleDelete}
className="flex items-center gap-1 px-3 py-1.5 bg-red-600/20 hover:bg-red-600/40 rounded text-red-400 text-sm transition-colors"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
)}
</div>
<div className="flex-1 overflow-auto p-4">
{selectedCode ? (
<div className="space-y-4">
{/* 코드 */}
<div>
<label className="block text-sm font-medium text-white/70 mb-1"></label>
{isEditing ? (
<input
type="text"
value={editData.code ?? selectedCode.code}
onChange={(e) => setEditData({ ...editData, code: e.target.value })}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
/>
) : (
<div className="px-3 py-2 bg-white/5 rounded-lg text-white">{selectedCode.code}</div>
)}
</div>
{/* 값(S) */}
<div>
<label className="block text-sm font-medium text-white/70 mb-1"> (String)</label>
{isEditing ? (
<input
type="text"
value={editData.svalue ?? selectedCode.svalue}
onChange={(e) => setEditData({ ...editData, svalue: e.target.value })}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
/>
) : (
<div className="px-3 py-2 bg-white/5 rounded-lg text-white">{selectedCode.svalue || '-'}</div>
)}
</div>
{/* 값(I) */}
<div>
<label className="block text-sm font-medium text-white/70 mb-1"> (Integer)</label>
{isEditing ? (
<input
type="number"
value={editData.ivalue ?? selectedCode.ivalue}
onChange={(e) => setEditData({ ...editData, ivalue: parseInt(e.target.value) || 0 })}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
/>
) : (
<div className="px-3 py-2 bg-white/5 rounded-lg text-white">{selectedCode.ivalue}</div>
)}
</div>
{/* 값(F) */}
<div>
<label className="block text-sm font-medium text-white/70 mb-1"> (Float)</label>
{isEditing ? (
<input
type="number"
step="0.01"
value={editData.fvalue ?? selectedCode.fvalue}
onChange={(e) => setEditData({ ...editData, fvalue: parseFloat(e.target.value) || 0 })}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
/>
) : (
<div className="px-3 py-2 bg-white/5 rounded-lg text-white">{selectedCode.fvalue}</div>
)}
</div>
{/* 설명 */}
<div>
<label className="block text-sm font-medium text-white/70 mb-1"></label>
{isEditing ? (
<textarea
value={editData.memo ?? selectedCode.memo}
onChange={(e) => setEditData({ ...editData, memo: e.target.value })}
rows={3}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white resize-none"
/>
) : (
<div className="px-3 py-2 bg-white/5 rounded-lg text-white min-h-[80px]">{selectedCode.memo || '-'}</div>
)}
</div>
{/* 편집 모드 버튼 */}
{isEditing && (
<div className="flex items-center gap-2 pt-4">
<button
onClick={handleSave}
className="flex items-center gap-1 px-4 py-2 bg-green-600 hover:bg-green-700 rounded-lg text-white transition-colors"
>
<Save className="w-4 h-4" />
</button>
<button
onClick={handleCancel}
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white transition-colors"
>
</button>
</div>
)}
</div>
) : (
<div className="h-full flex items-center justify-center text-white/50">
.
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,402 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import {
ShoppingCart,
FileCheck,
AlertTriangle,
CheckCircle,
Flag,
RefreshCw,
ClipboardList,
Clock,
} from 'lucide-react';
import { comms } from '@/communication';
import { TodoModel, PurchaseItem } from '@/types';
interface StatCardProps {
title: string;
value: number | string;
icon: React.ReactNode;
color: string;
onClick?: () => void;
}
function StatCard({ title, value, icon, color, onClick }: StatCardProps) {
return (
<div
onClick={onClick}
className={`glass-effect rounded-2xl p-6 card-hover ${onClick ? 'cursor-pointer' : ''}`}
>
<div className="flex items-center justify-between">
<div>
<p className="text-white/60 text-sm font-medium">{title}</p>
<p className={`text-3xl font-bold mt-2 ${color}`}>{value}</p>
</div>
<div className={`p-3 rounded-xl ${color.replace('text-', 'bg-').replace('-400', '-500/20')}`}>
{icon}
</div>
</div>
</div>
);
}
export function Dashboard() {
const navigate = useNavigate();
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
// 통계 데이터
const [purchaseNR, setPurchaseNR] = useState(0);
const [purchaseCR, setPurchaseCR] = useState(0);
const [todoCount, setTodoCount] = useState(0);
const [todayWorkHrs, setTodayWorkHrs] = useState(0);
// 목록 데이터
const [urgentTodos, setUrgentTodos] = useState<TodoModel[]>([]);
const [purchaseNRList, setPurchaseNRList] = useState<PurchaseItem[]>([]);
const [purchaseCRList, setPurchaseCRList] = useState<PurchaseItem[]>([]);
// 모달 상태
const [showNRModal, setShowNRModal] = useState(false);
const [showCRModal, setShowCRModal] = useState(false);
const loadDashboardData = useCallback(async () => {
try {
// 오늘 날짜 (로컬 시간 기준)
const now = new Date();
const todayStr = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
// 현재 로그인 사용자 ID 가져오기
let currentUserId = '';
try {
const loginStatus = await comms.checkLoginStatus();
if (loginStatus.Success && loginStatus.IsLoggedIn && loginStatus.User) {
currentUserId = loginStatus.User.Id;
}
} catch (error) {
console.error('로그인 정보 로드 오류:', error);
}
// 병렬로 데이터 로드
const [
purchaseCount,
urgentTodosResponse,
allTodosResponse,
jobreportResponse,
] = await Promise.all([
comms.getPurchaseWaitCount(),
comms.getUrgentTodos(),
comms.getTodos(),
comms.getJobReportList(todayStr, todayStr, currentUserId, ''),
]);
setPurchaseNR(purchaseCount.NR);
setPurchaseCR(purchaseCount.CR);
if (urgentTodosResponse.Success && urgentTodosResponse.Data) {
setUrgentTodos(urgentTodosResponse.Data.slice(0, 5));
}
if (allTodosResponse.Success && allTodosResponse.Data) {
// 진행, 대기 상태의 할일만 카운트 (보류, 취소 제외)
const pendingCount = allTodosResponse.Data.filter(t => 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);
setTodayWorkHrs(totalHrs);
} else {
setTodayWorkHrs(0);
}
} catch (error) {
console.error('대시보드 데이터 로드 오류:', error);
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
const loadNRList = async () => {
try {
const list = await comms.getPurchaseNRList();
setPurchaseNRList(list);
setShowNRModal(true);
} catch (error) {
console.error('NR 목록 로드 오류:', error);
}
};
const loadCRList = async () => {
try {
const list = await comms.getPurchaseCRList();
setPurchaseCRList(list);
setShowCRModal(true);
} catch (error) {
console.error('CR 목록 로드 오류:', error);
}
};
useEffect(() => {
loadDashboardData();
// 30초마다 자동 새로고침
const interval = setInterval(loadDashboardData, 30000);
return () => clearInterval(interval);
}, [loadDashboardData]);
const handleRefresh = () => {
setRefreshing(true);
loadDashboardData();
};
const getStatusText = (status: string) => {
switch (status) {
case '0': return '대기';
case '1': return '진행';
case '2': return '취소';
case '3': return '보류';
case '5': return '완료';
default: return '대기';
}
};
const getStatusClass = (status: string) => {
switch (status) {
case '0': return 'bg-gray-500/20 text-gray-300';
case '1': return 'bg-primary-500/20 text-primary-300';
case '2': return 'bg-danger-500/20 text-danger-300';
case '3': return 'bg-warning-500/20 text-warning-300';
case '5': return 'bg-success-500/20 text-success-300';
default: return 'bg-white/10 text-white/50';
}
};
const getPriorityText = (seqno: number) => {
switch (seqno) {
case 1: return '중요';
case 2: return '매우 중요';
case 3: return '긴급';
default: return '보통';
}
};
const getPriorityClass = (seqno: number) => {
switch (seqno) {
case 1: return 'bg-primary-500/20 text-primary-300';
case 2: return 'bg-warning-500/20 text-warning-300';
case 3: return 'bg-danger-500/20 text-danger-300';
default: return 'bg-white/10 text-white/50';
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
</div>
);
}
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>
{/* 통계 카드 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<StatCard
title="구매요청 (NR)"
value={purchaseNR}
icon={<ShoppingCart className="w-6 h-6 text-primary-400" />}
color="text-primary-400"
onClick={loadNRList}
/>
<StatCard
title="구매요청 (CR)"
value={purchaseCR}
icon={<FileCheck className="w-6 h-6 text-success-400" />}
color="text-success-400"
onClick={loadCRList}
/>
<StatCard
title="미완료 할일"
value={todoCount}
icon={<ClipboardList className="w-6 h-6 text-warning-400" />}
color="text-warning-400"
onClick={() => navigate('/todo')}
/>
<StatCard
title="금일 업무일지"
value={`${todayWorkHrs}시간`}
icon={<Clock className="w-6 h-6 text-cyan-400" />}
color="text-cyan-400"
onClick={() => navigate('/jobreport')}
/>
</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">
<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>
<div className="divide-y divide-white/10">
{urgentTodos.length > 0 ? (
urgentTodos.map((todo) => (
<div
key={todo.idx}
className="px-6 py-4 hover:bg-white/5 transition-colors cursor-pointer"
onClick={() => navigate('/todo')}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4">
{todo.flag && (
<Flag className="w-4 h-4 text-warning-400" />
)}
<div>
<p className="text-white font-medium">
{todo.title || '제목 없음'}
</p>
<p className="text-white/60 text-sm line-clamp-1">
{todo.remark}
</p>
</div>
</div>
<div className="flex items-center space-x-3">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getPriorityClass(todo.seqno)}`}>
{getPriorityText(todo.seqno)}
</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(todo.status)}`}>
{getStatusText(todo.status)}
</span>
{todo.expire && (
<span className={`text-xs ${new Date(todo.expire) < new Date() ? 'text-danger-400' : 'text-white/60'}`}>
{new Date(todo.expire).toLocaleDateString('ko-KR')}
</span>
)}
</div>
</div>
</div>
))
) : (
<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>
</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>
)}
</div>
);
}
// 모달 컴포넌트
interface ModalProps {
title: string;
onClose: () => void;
children: React.ReactNode;
}
function Modal({ title, onClose, children }: ModalProps) {
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50 flex items-center justify-center p-4">
<div className="glass-effect rounded-2xl w-full max-w-4xl max-h-[80vh] overflow-hidden animate-slide-up">
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
<h3 className="text-lg font-semibold text-white">{title}</h3>
<button
onClick={onClose}
className="text-white/70 hover:text-white transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="overflow-auto max-h-[calc(80vh-80px)]">
{children}
</div>
</div>
</div>
);
}
// 구매 테이블 컴포넌트
function PurchaseTable({ data }: { data: PurchaseItem[] }) {
if (data.length === 0) {
return (
<div className="px-6 py-8 text-center text-white/50">
</div>
);
}
return (
<table className="w-full">
<thead className="bg-white/10">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-right text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-right text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-right text-xs font-medium text-white/70 uppercase"></th>
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{data.map((item, idx) => (
<tr key={idx} className="hover:bg-white/5">
<td className="px-4 py-3 text-white/80 text-sm">{item.pdate}</td>
<td className="px-4 py-3 text-white/80 text-sm">{item.process}</td>
<td className="px-4 py-3 text-white text-sm">{item.pumname}</td>
<td className="px-4 py-3 text-white/80 text-sm">{item.pumscale}</td>
<td className="px-4 py-3 text-white/80 text-sm text-right">
{item.pumqtyreq?.toLocaleString()} {item.pumunit}
</td>
<td className="px-4 py-3 text-white/80 text-sm text-right">
{item.pumprice?.toLocaleString()}
</td>
<td className="px-4 py-3 text-white text-sm text-right font-medium">
{item.pumamt?.toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
);
}

View File

@@ -0,0 +1,230 @@
import { useState, useEffect } from 'react';
import { Search, Plus, Package } from 'lucide-react';
import { clsx } from 'clsx';
import { comms } from '@/communication';
import { ItemInfo } from '@/types';
import { ItemEditDialog } from '@/components/items';
export function ItemsPage() {
const [categories, setCategories] = useState<string[]>([]);
const [items, setItems] = useState<ItemInfo[]>([]);
const [selectedCategory, setSelectedCategory] = useState<string>('all');
const [searchKey, setSearchKey] = useState('');
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState('');
const [dialogOpen, setDialogOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState<ItemInfo | null>(null);
useEffect(() => {
loadCategories();
}, []);
const loadCategories = async () => {
try {
const result = await comms.getItemCategories();
if (result.Success && result.Data) {
setCategories(result.Data);
}
} catch (error) {
console.error('카테고리 로드 실패:', error);
}
};
const loadItems = async () => {
if (!searchKey.trim()) {
alert('검색어를 입력하세요');
return;
}
setLoading(true);
try {
const result = await comms.getItemList(selectedCategory, searchKey);
setItems(result);
} catch (error) {
console.error('품목 로드 실패:', error);
} finally {
setLoading(false);
}
};
const handleSave = async (item: ItemInfo) => {
const response = await comms.saveItem(item);
if (response.Success) {
setDialogOpen(false);
setSelectedItem(null);
if (searchKey) loadItems();
} else {
alert(response.Message || '저장 실패');
}
};
const handleDelete = async (idx: number) => {
const response = await comms.deleteItem(idx);
if (response.Success) {
setDialogOpen(false);
setSelectedItem(null);
setItems(items.filter((i) => i.idx !== idx));
} else {
alert(response.Message || '삭제 실패');
}
};
const handleAddNew = () => {
const newItem: ItemInfo = {
idx: 0,
sid: '',
cate: selectedCategory !== 'all' ? selectedCategory : '',
name: '',
model: '',
scale: '',
unit: '',
price: 0,
supply: '',
manu: '',
storage: '',
disable: false,
memo: '',
};
setSelectedItem(newItem);
setDialogOpen(true);
};
const handleRowClick = (item: ItemInfo) => {
setSelectedItem(item);
setDialogOpen(true);
};
const filteredItems = items.filter(
(i) =>
(i.sid ?? '').toLowerCase().includes(filter.toLowerCase()) ||
(i.name ?? '').toLowerCase().includes(filter.toLowerCase()) ||
(i.model ?? '').toLowerCase().includes(filter.toLowerCase())
);
return (
<div className="h-full flex flex-col">
{/* 헤더 */}
<div className="glass-effect rounded-xl p-4 mb-4">
<div className="flex items-center gap-4 flex-wrap">
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white min-w-[150px]"
>
<option value="all" className="bg-slate-800">-- --</option>
{categories.map((c) => (
<option key={c} value={c} className="bg-slate-800">{c}</option>
))}
</select>
<div className="flex-1 flex items-center gap-2">
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40" />
<input
type="text"
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && loadItems()}
placeholder="품목명/SID/모델 검색..."
className="w-full pl-9 pr-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40"
/>
</div>
<button
onClick={loadItems}
className="flex items-center gap-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white transition-colors"
>
<Search className="w-4 h-4" />
</button>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="필터..."
className="px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40 w-32"
/>
<button
onClick={handleAddNew}
className="flex items-center gap-1 px-3 py-2 bg-green-600 hover:bg-green-700 rounded-lg text-white transition-colors"
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* 테이블 */}
<div className="glass-effect rounded-xl flex-1 overflow-hidden flex flex-col">
<div className="p-4 border-b border-white/10 flex items-center gap-2">
<Package className="w-5 h-5 text-white/70" />
<h2 className="text-lg font-semibold text-white"> </h2>
<span className="text-sm text-white/50">({filteredItems.length})</span>
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white"></div>
</div>
) : (
<table className="w-full text-sm">
<thead className="bg-white/5 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-white/70 w-28">SID</th>
<th className="px-3 py-2 text-left font-medium text-white/70 w-20"></th>
<th className="px-3 py-2 text-left font-medium text-white/70"></th>
<th className="px-3 py-2 text-left font-medium text-white/70"></th>
<th className="px-3 py-2 text-right font-medium text-white/70 w-24"></th>
<th className="px-3 py-2 text-left font-medium text-white/70 w-24"></th>
<th className="px-3 py-2 text-left font-medium text-white/70 w-24"></th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{filteredItems.map((item) => (
<tr
key={item.idx || 'new'}
onClick={() => handleRowClick(item)}
className={clsx(
'hover:bg-white/10 transition-colors cursor-pointer',
item.disable && 'opacity-50'
)}
>
<td className="px-3 py-2 text-white font-mono">{item.sid}</td>
<td className="px-3 py-2 text-white/70">{item.cate}</td>
<td className="px-3 py-2 text-white">{item.name}</td>
<td className="px-3 py-2 text-white/70">{item.model}</td>
<td className="px-3 py-2 text-white text-right">{(item.price ?? 0).toLocaleString()}</td>
<td className="px-3 py-2 text-white/70">{item.supply}</td>
<td className="px-3 py-2 text-white/70">{item.manu}</td>
</tr>
))}
{filteredItems.length === 0 && (
<tr>
<td colSpan={7} className="px-4 py-8 text-center text-white/50">
{items.length === 0 ? '검색어를 입력하고 검색 버튼을 클릭하세요.' : '검색 결과가 없습니다.'}
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
</div>
{/* 편집 다이얼로그 */}
<ItemEditDialog
item={selectedItem}
isOpen={dialogOpen}
onClose={() => {
setDialogOpen(false);
setSelectedItem(null);
}}
onSave={handleSave}
onDelete={handleDelete}
/>
</div>
);
}

View File

@@ -0,0 +1,603 @@
import { useState, useEffect, useCallback } from 'react';
import {
FileText,
Search,
RefreshCw,
Copy,
Plus,
} from 'lucide-react';
import { comms } from '@/communication';
import { JobReportItem, JobReportUser } from '@/types';
import { JobreportEditModal, JobreportFormData, initialFormData } from '@/components/jobreport/JobreportEditModal';
export function Jobreport() {
const [jobreportList, setJobreportList] = useState<JobReportItem[]>([]);
const [users, setUsers] = useState<JobReportUser[]>([]);
const [loading, setLoading] = useState(false);
const [processing, setProcessing] = useState(false);
// 검색 조건
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [selectedUser, setSelectedUser] = useState('');
const [searchKey, setSearchKey] = useState('');
// 모달 상태
const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState<JobReportItem | null>(null);
const [formData, setFormData] = useState<JobreportFormData>(initialFormData);
// 페이징 상태
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 15;
// 권한 상태
const [canViewOT, setCanViewOT] = useState(false);
// 오늘 근무시간 상태
const [todayWork, setTodayWork] = useState({ hrs: 0, ot: 0 });
// 날짜 포맷 헬퍼 함수 (로컬 시간 기준)
const formatDateLocal = (date: Date) => {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
};
// 오늘 근무시간 로드
const loadTodayWork = useCallback(async (userId: string) => {
const todayStr = formatDateLocal(new Date());
try {
const response = await comms.getJobReportList(todayStr, todayStr, userId, '');
if (response.Success && response.Data) {
// 웹소켓 모드에서 응답 혼선 방지를 위해 오늘 날짜 데이터만 필터링
const todayData = response.Data.filter(item => {
const itemDate = item.pdate?.substring(0, 10);
return itemDate === todayStr;
});
const work = todayData.reduce((acc, item) => ({
hrs: acc.hrs + (item.hrs || 0),
ot: acc.ot + (item.ot || 0)
}), { hrs: 0, ot: 0 });
setTodayWork(work);
}
} catch (error) {
console.error('오늘 근무시간 로드 오류:', error);
}
}, []);
// 초기화 완료 플래그
const [initialized, setInitialized] = useState(false);
// 날짜 및 사용자 정보 초기화
useEffect(() => {
const initialize = async () => {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const sd = formatDateLocal(startOfMonth);
const ed = formatDateLocal(endOfMonth);
setStartDate(sd);
setEndDate(ed);
// 현재 로그인 사용자 정보 로드
let userId = '';
try {
const loginStatus = await comms.checkLoginStatus();
if (loginStatus.Success && loginStatus.IsLoggedIn && loginStatus.User) {
userId = loginStatus.User.Id;
setSelectedUser(userId);
}
} catch (error) {
console.error('로그인 정보 로드 오류:', error);
}
// 사용자 목록 로드
loadUsers();
// 권한 로드 (본인 조회이므로 canViewOT = true)
try {
const perm = await comms.getJobReportPermission(userId);
if (perm.Success) {
setCanViewOT(perm.CanViewOT);
}
} catch (error) {
console.error('권한 정보 로드 오류:', error);
}
// 초기화 완료 표시
setInitialized(true);
};
initialize();
}, []);
// 초기화 완료 후 조회 실행
useEffect(() => {
if (initialized && startDate && endDate && selectedUser) {
handleSearchAndLoadToday();
}
}, [initialized, startDate, endDate, selectedUser]);
// 검색 + 오늘 근무시간 로드 (순차 실행)
const handleSearchAndLoadToday = async () => {
await handleSearch();
loadTodayWork(selectedUser);
};
// 사용자 목록 로드
const loadUsers = async () => {
try {
const result = await comms.getJobReportUsers();
setUsers(result || []);
} catch (error) {
console.error('사용자 목록 로드 오류:', error);
}
};
// 데이터 로드
const loadData = useCallback(async () => {
if (!startDate || !endDate) return;
setLoading(true);
try {
const response = await comms.getJobReportList(startDate, endDate, selectedUser, searchKey);
if (response.Success && response.Data) {
setJobreportList(response.Data);
} else {
setJobreportList([]);
}
} catch (error) {
console.error('업무일지 목록 로드 오류:', error);
alert('데이터를 불러오는 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
}, [startDate, endDate, selectedUser, searchKey]);
// 검색
const handleSearch = async () => {
if (new Date(startDate) > new Date(endDate)) {
alert('시작일은 종료일보다 늦을 수 없습니다.');
return;
}
// 선택된 담당자에 따라 권한 재확인
try {
const perm = await comms.getJobReportPermission(selectedUser);
if (perm.Success) {
setCanViewOT(perm.CanViewOT);
}
} catch (error) {
console.error('권한 정보 로드 오류:', error);
}
await loadData();
};
// 새 업무일지 추가 모달
const openAddModal = () => {
setEditingItem(null);
setFormData(initialFormData);
setShowModal(true);
};
// 복사하여 새 업무일지 생성 모달
const openCopyModal = async (item: JobReportItem, e: React.MouseEvent) => {
e.stopPropagation(); // 행 클릭 이벤트 방지
try {
const response = await comms.getJobReportDetail(item.idx);
if (response.Success && response.Data) {
const data = response.Data;
setEditingItem(null); // 새로 추가하는 것이므로 null
setFormData({
pdate: new Date().toISOString().split('T')[0], // 오늘 날짜
projectName: data.projectName || '',
requestpart: data.requestpart || '',
package: data.package || '',
type: data.type || '',
process: data.process || '',
status: data.status || '진행 완료',
description: data.description || '',
hrs: 0, // 시간 초기화
ot: 0, // OT 초기화
jobgrp: '',
tag: '',
});
setShowModal(true);
}
} catch (error) {
console.error('업무일지 조회 오류:', error);
alert('데이터를 불러오는 중 오류가 발생했습니다.');
}
};
// 편집 모달
const openEditModal = async (item: JobReportItem) => {
try {
const response = await comms.getJobReportDetail(item.idx);
if (response.Success && response.Data) {
const data = response.Data;
setEditingItem(data);
setFormData({
pdate: data.pdate ? data.pdate.split('T')[0] : '',
projectName: data.projectName || '',
requestpart: data.requestpart || '',
package: data.package || '',
type: data.type || '',
process: data.process || '',
status: data.status || '진행 완료',
description: data.description || '',
hrs: data.hrs || 0,
ot: data.ot || 0,
jobgrp: '', // 뷰에 없는 필드
tag: '', // 뷰에 없는 필드
});
setShowModal(true);
}
} catch (error) {
console.error('업무일지 조회 오류:', error);
alert('데이터를 불러오는 중 오류가 발생했습니다.');
}
};
// 저장
const handleSave = async () => {
if (!formData.pdate) {
alert('날짜를 입력해주세요.');
return;
}
if (!formData.projectName.trim()) {
alert('프로젝트명을 입력해주세요.');
return;
}
setProcessing(true);
try {
let response;
if (editingItem) {
const itemIdx = editingItem.idx ?? (editingItem as unknown as Record<string, unknown>)['Idx'] as number;
if (!itemIdx) {
alert('수정할 항목의 ID를 찾을 수 없습니다.');
setProcessing(false);
return;
}
response = await comms.editJobReport(
itemIdx,
formData.pdate || '',
formData.projectName || '',
formData.requestpart || '',
formData.package || '',
formData.type || '',
formData.process || '',
formData.status || '진행 완료',
formData.description || '',
formData.hrs || 0,
formData.ot || 0,
formData.jobgrp || '',
formData.tag || ''
);
} else {
response = await comms.addJobReport(
formData.pdate || '',
formData.projectName || '',
formData.requestpart || '',
formData.package || '',
formData.type || '',
formData.process || '',
formData.status || '진행 완료',
formData.description || '',
formData.hrs || 0,
formData.ot || 0,
formData.jobgrp || '',
formData.tag || ''
);
}
if (response.Success) {
setShowModal(false);
loadData();
loadTodayWork(selectedUser);
} else {
alert(response.Message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('저장 오류:', error);
alert('서버 연결에 실패했습니다: ' + (error instanceof Error ? error.message : String(error)));
} finally {
setProcessing(false);
}
};
// 삭제
const handleDelete = async (id: number) => {
if (!confirm('정말로 이 업무일지를 삭제하시겠습니까?')) return;
setProcessing(true);
try {
const response = await comms.deleteJobReport(id);
if (response.Success) {
alert('삭제되었습니다.');
loadData();
loadTodayWork(selectedUser);
} 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 totalPages = Math.ceil(jobreportList.length / pageSize);
const paginatedList = jobreportList.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 gap-6">
{/* 좌측: 필터 영역 */}
<div className="flex-1">
<div className="flex items-start gap-3">
{/* 필터 입력 영역: 2행 2열 */}
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
{/* 1행: 시작일, 담당자 */}
<div className="flex items-center gap-2">
<label className="text-white/70 text-sm font-medium whitespace-nowrap w-12"></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"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-white/70 text-sm font-medium whitespace-nowrap w-12"></label>
<select
value={selectedUser}
onChange={(e) => setSelectedUser(e.target.value)}
className="w-44 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"
>
<option value="" className="bg-gray-800"></option>
{users.map((user) => (
<option key={user.id} value={user.id} className="bg-gray-800">
{user.name}({user.id})
</option>
))}
</select>
</div>
{/* 2행: 종료일, 검색어 */}
<div className="flex items-center gap-2">
<label className="text-white/70 text-sm font-medium whitespace-nowrap w-12"></label>
<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 w-12"></label>
<input
type="text"
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearchWithReset()}
placeholder="프로젝트, 내용 등"
className="w-44 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>
</div>
{/* 버튼 영역: 우측 수직 배치 */}
<div className="grid grid-rows-2 gap-y-3">
<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>
{/* 우측: 오늘 근무시간 */}
<div className="flex-shrink-0 w-48">
<div className="bg-white/10 rounded-xl p-4 h-full flex flex-col justify-center">
<div className="text-white/70 text-sm font-medium mb-2 text-center"> </div>
<div className="text-center">
<span className="text-3xl font-bold text-white">{todayWork.hrs}</span>
<span className="text-white/70 text-lg ml-1"></span>
</div>
{todayWork.ot > 0 && (
<div className="text-center mt-1">
<span className="text-warning-400 text-sm">OT: {todayWork.ot}</span>
</div>
)}
</div>
</div>
</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">{jobreportList.length}</span>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-white/10">
<tr>
<th className="px-2 py-3 text-center text-xs font-medium text-white/70 uppercase w-10"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
{canViewOT && <th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">OT</th>}
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{loading ? (
<tr>
<td colSpan={canViewOT ? 8 : 7} className="px-4 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>
</td>
</tr>
) : jobreportList.length === 0 ? (
<tr>
<td colSpan={canViewOT ? 8 : 7} className="px-4 py-8 text-center text-white/50">
.
</td>
</tr>
) : (
paginatedList.map((item) => (
<tr
key={item.idx}
className={`hover:bg-white/5 transition-colors cursor-pointer ${item.type === '휴가' ? 'bg-gradient-to-r from-lime-400/30 via-emerald-400/20 to-teal-400/30' : ''}`}
onClick={() => openEditModal(item)}
>
<td className="px-2 py-3 text-center">
<button
onClick={(e) => openCopyModal(item, e)}
className="text-white/40 hover:text-primary-400 transition-colors"
title="복사하여 새로 작성"
>
<Copy className="w-4 h-4" />
</button>
</td>
<td className="px-4 py-3 text-white text-sm">{formatDate(item.pdate)}</td>
<td className={`px-4 py-3 text-sm font-medium max-w-xs truncate ${item.pidx && item.pidx > 0 ? 'text-white' : 'text-white/50'}`} title={item.projectName}>
{item.projectName || '-'}
</td>
<td className="px-4 py-3 text-white text-sm">{item.type || '-'}</td>
<td className="px-4 py-3 text-sm">
<span className={`px-2 py-1 rounded text-xs ${
item.status?.includes('완료') ? 'bg-green-500/20 text-green-400' : 'bg-white/20 text-white/70'
}`}>
{item.status || '-'}
</span>
</td>
<td className="px-4 py-3 text-white text-sm">
{item.hrs || 0}h
</td>
{canViewOT && (
<td className="px-4 py-3 text-white text-sm">
{item.ot ? <span className="text-warning-400">{item.ot}h</span> : '-'}
</td>
)}
<td className="px-4 py-3 text-white text-sm">{item.name || item.id || '-'}</td>
</tr>
))
)}
</tbody>
</table>
</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">
{jobreportList.length} {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, jobreportList.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>
{/* 추가/수정 모달 */}
<JobreportEditModal
isOpen={showModal}
editingItem={editingItem}
formData={formData}
processing={processing}
onClose={() => setShowModal(false)}
onFormChange={setFormData}
onSave={handleSave}
onDelete={(idx) => {
handleDelete(idx);
setShowModal(false);
}}
/>
</div>
);
}

View File

@@ -0,0 +1,286 @@
import { useState, useEffect, useCallback } from 'react';
import {
Calendar,
Search,
Trash2,
AlertTriangle,
Clock,
CheckCircle,
XCircle,
RefreshCw,
} from 'lucide-react';
import { comms } from '@/communication';
import { KuntaeModel } from '@/types';
export function Kuntae() {
const [kuntaeList, setKuntaeList] = useState<KuntaeModel[]>([]);
const [loading, setLoading] = useState(false);
const [processing, setProcessing] = useState(false);
// 검색 조건
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
// 통계
const [stats, setStats] = useState({
holyUsed: 0,
alternateUsed: 0,
holyRemain: 0,
alternateRemain: 0,
});
// 날짜 초기화 (현재 월)
useEffect(() => {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
setStartDate(startOfMonth.toISOString().split('T')[0]);
setEndDate(endOfMonth.toISOString().split('T')[0]);
}, []);
// 데이터 로드
const loadData = useCallback(async () => {
if (!startDate || !endDate) return;
setLoading(true);
try {
const response = await comms.getKuntaeList(startDate, endDate);
if (response.Success && response.Data) {
setKuntaeList(response.Data);
updateStats(response.Data);
} else {
setKuntaeList([]);
}
} catch (error) {
console.error('근태 목록 로드 오류:', error);
alert('데이터를 불러오는 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
}, [startDate, endDate]);
// 통계 업데이트
const updateStats = (data: KuntaeModel[]) => {
const holyUsed = data.filter(item => item.cate === '연차' || item.cate === '휴가').length;
const alternateUsed = data.filter(item => item.cate === '대체').length;
setStats({
holyUsed,
alternateUsed,
holyRemain: 15 - holyUsed, // 예시 값
alternateRemain: 5 - alternateUsed, // 예시 값
});
};
// 검색
const handleSearch = () => {
if (new Date(startDate) > new Date(endDate)) {
alert('시작일은 종료일보다 늦을 수 없습니다.');
return;
}
loadData();
};
// 삭제
const handleDelete = async (id: number) => {
if (!confirm('정말로 이 근태 데이터를 삭제하시겠습니까?')) return;
setProcessing(true);
try {
const response = await comms.deleteKuntae(id);
if (response.Success) {
alert('삭제되었습니다.');
loadData();
} else {
alert(response.Message || '삭제에 실패했습니다.');
}
} catch (error) {
console.error('삭제 오류:', error);
alert('서버 연결에 실패했습니다.');
} finally {
setProcessing(false);
}
};
// 날짜 포맷
const formatDate = (dateStr: string | null) => {
if (!dateStr) return '-';
return new Date(dateStr).toLocaleDateString('ko-KR');
};
return (
<div className="space-y-6 animate-fade-in">
{/* 개발중 경고 */}
<div className="bg-warning-500/20 border border-warning-500/30 rounded-xl p-4 flex items-center">
<AlertTriangle className="w-5 h-5 text-warning-400 mr-3 flex-shrink-0" />
<div>
<p className="text-white font-medium"> </p>
<p className="text-white/60 text-sm"> .</p>
</div>
</div>
{/* 검색 필터 */}
<div className="glass-effect rounded-2xl p-6">
<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>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-full bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2"></label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-full bg-white/20 border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
</div>
<div className="flex items-end">
<button
onClick={handleSearch}
disabled={loading}
className="w-full bg-primary-500 hover:bg-primary-600 text-white px-4 py-2 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>
{/* 통계 카드 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<StatCard
title="휴가 사용"
value={stats.holyUsed}
icon={<Calendar className="w-6 h-6 text-primary-400" />}
color="text-primary-400"
/>
<StatCard
title="대체 사용"
value={stats.alternateUsed}
icon={<CheckCircle className="w-6 h-6 text-success-400" />}
color="text-success-400"
/>
<StatCard
title="잔량 (연차)"
value={stats.holyRemain}
icon={<Clock className="w-6 h-6 text-warning-400" />}
color="text-warning-400"
/>
<StatCard
title="잔량 (대체)"
value={stats.alternateRemain}
icon={<XCircle className="w-6 h-6 text-danger-400" />}
color="text-danger-400"
/>
</div>
{/* 데이터 테이블 */}
<div className="glass-effect rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-white/10">
<h3 className="text-lg font-semibold text-white"> </h3>
</div>
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-white/10">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">()</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{loading ? (
<tr>
<td colSpan={11} className="px-4 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>
</td>
</tr>
) : kuntaeList.length === 0 ? (
<tr>
<td colSpan={11} className="px-4 py-8 text-center text-white/50">
.
</td>
</tr>
) : (
kuntaeList.map((item) => (
<tr key={item.idx} className="hover:bg-white/5 transition-colors">
<td className="px-4 py-3 text-white text-sm">{item.cate || '-'}</td>
<td className="px-4 py-3 text-white text-sm">{formatDate(item.sdate)}</td>
<td className="px-4 py-3 text-white text-sm">{formatDate(item.edate)}</td>
<td className="px-4 py-3 text-white text-sm">{item.uid || '-'}</td>
<td className="px-4 py-3 text-white text-sm">{item.uname || '-'}</td>
<td className="px-4 py-3 text-white text-sm">{item.term || '-'}</td>
<td className="px-4 py-3 text-white/80 text-sm max-w-xs truncate" title={item.contents}>
{item.contents || '-'}
</td>
<td className="px-4 py-3 text-white text-sm">{item.extcate || '-'}</td>
<td className="px-4 py-3 text-white text-sm">{item.wuid || '-'}</td>
<td className="px-4 py-3 text-white text-sm">{item.wdate || '-'}</td>
<td className="px-4 py-3 text-sm">
<button
onClick={() => handleDelete(item.idx)}
disabled={processing}
className="text-danger-400 hover:text-danger-300 transition-colors disabled:opacity-50"
title="삭제"
>
<Trash2 className="w-4 h-4" />
</button>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
}
// 통계 카드 컴포넌트
interface StatCardProps {
title: string;
value: number;
icon: React.ReactNode;
color: string;
}
function StatCard({ title, value, icon, color }: StatCardProps) {
return (
<div className="glass-effect rounded-xl p-4 card-hover">
<div className="flex items-center">
<div className={`p-3 rounded-lg ${color.replace('text-', 'bg-').replace('-400', '-500/20')}`}>
{icon}
</div>
<div className="ml-4">
<p className="text-sm font-medium text-white/70">{title}</p>
<p className={`text-2xl font-bold ${color}`}>{value}</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,202 @@
import { useState, useEffect } from 'react';
import { LogIn, Building2, User, Lock, Loader2 } from 'lucide-react';
import { comms } from '@/communication';
import { UserGroup } from '@/types';
interface LoginProps {
onLoginSuccess: () => void;
}
export function Login({ onLoginSuccess }: LoginProps) {
const [groups, setGroups] = useState<UserGroup[]>([]);
const [gcode, setGcode] = useState('');
const [userId, setUserId] = useState('');
const [password, setPassword] = useState('');
const [rememberMe, setRememberMe] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [initialLoading, setInitialLoading] = useState(true);
useEffect(() => {
loadInitialData();
}, []);
const loadInitialData = async () => {
try {
// 그룹 목록 및 이전 로그인 정보 로드
const [groupsData, prevInfo] = await Promise.all([
comms.getUserGroups(),
comms.getPreviousLoginInfo()
]);
console.log('[Login] 부서 목록:', groupsData);
setGroups(groupsData);
if (prevInfo.Success && prevInfo.Data) {
if (prevInfo.Data.LastGcode) {
setGcode(prevInfo.Data.LastGcode);
}
if (prevInfo.Data.LastId) {
setUserId(prevInfo.Data.LastId);
setRememberMe(true);
}
}
} catch (err) {
console.error('초기 데이터 로드 실패:', err);
} finally {
setInitialLoading(false);
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setLoading(true);
try {
const result = await comms.login(gcode, userId, password, rememberMe);
if (result.Success) {
if (result.VersionWarning) {
alert(result.VersionWarning);
}
onLoginSuccess();
} else {
setError(result.Message || '로그인에 실패했습니다.');
}
} catch (err) {
setError('서버 연결에 실패했습니다.');
console.error('로그인 오류:', err);
} finally {
setLoading(false);
}
};
if (initialLoading) {
return (
<div className="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 flex items-center justify-center">
<Loader2 className="w-8 h-8 text-white animate-spin" />
</div>
);
}
return (
<div className="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* Logo */}
<div className="text-center mb-8">
<h1 className="text-4xl font-bold text-white mb-2">GroupWare</h1>
<p className="text-white/60">ATK4-EET</p>
</div>
{/* Login Form */}
<div className="glass-effect rounded-2xl p-8">
<h2 className="text-xl font-semibold text-white mb-6 text-center"></h2>
<form onSubmit={handleSubmit} className="space-y-5">
{/* 부서 선택 */}
<div>
<label className="block text-sm font-medium text-white/70 mb-2">
</label>
<div className="relative">
<Building2 className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-white/50" />
<select
value={gcode}
onChange={(e) => setGcode(e.target.value)}
className="w-full bg-white/10 border border-white/20 rounded-xl pl-10 pr-4 py-3 text-white focus:outline-none focus:border-primary-400 appearance-none"
required
>
<option value=""> </option>
{groups.filter(g => g.gcode).map((group) => (
<option key={group.gcode} value={group.gcode}>
{group.name}
</option>
))}
</select>
</div>
</div>
{/* 사용자 ID */}
<div>
<label className="block text-sm font-medium text-white/70 mb-2">
ID
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-white/50" />
<input
type="text"
value={userId}
onChange={(e) => setUserId(e.target.value)}
className="w-full bg-white/10 border border-white/20 rounded-lg pl-10 pr-4 py-3 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="ID를 입력하세요"
required
/>
</div>
</div>
{/* 비밀번호 */}
<div>
<label className="block text-sm font-medium text-white/70 mb-2">
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-white/50" />
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full bg-white/10 border border-white/20 rounded-lg pl-10 pr-4 py-3 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
placeholder="비밀번호를 입력하세요"
required
/>
</div>
</div>
{/* 로그인 정보 저장 */}
<div className="flex items-center">
<input
type="checkbox"
id="rememberMe"
checked={rememberMe}
onChange={(e) => setRememberMe(e.target.checked)}
className="w-4 h-4 rounded border-white/20 bg-white/10 text-primary-500 focus:ring-primary-500"
/>
<label htmlFor="rememberMe" className="ml-2 text-sm text-white/70">
</label>
</div>
{/* 에러 메시지 */}
{error && (
<div className="bg-danger-500/20 border border-danger-500/50 rounded-lg px-4 py-3 text-danger-300 text-sm">
{error}
</div>
)}
{/* 로그인 버튼 */}
<button
type="submit"
disabled={loading}
className="w-full bg-primary-500 hover:bg-primary-600 disabled:bg-primary-500/50 text-white font-medium py-3 rounded-lg transition-colors flex items-center justify-center space-x-2"
>
{loading ? (
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<>
<LogIn className="w-5 h-5" />
<span></span>
</>
)}
</button>
</form>
</div>
{/* Footer */}
<p className="text-center text-white/40 text-sm mt-6">
© 2024 GroupWare. All rights reserved.
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,474 @@
import { useState, useEffect, useCallback } from 'react';
import {
Mail,
Plus,
Edit2,
Trash2,
Save,
X,
Loader2,
RefreshCw,
Search,
} from 'lucide-react';
import { comms } from '@/communication';
import { MailFormItem } from '@/types';
const initialFormData: Partial<MailFormItem> = {
cate: '',
title: '',
tolist: '',
bcc: '',
cc: '',
subject: '',
tail: '',
body: '',
selfTo: false,
selfCC: false,
selfBCC: false,
exceptmail: '',
exceptmailcc: '',
};
export function MailFormPage() {
const [loading, setLoading] = useState(false);
const [mailForms, setMailForms] = useState<MailFormItem[]>([]);
const [searchKey, setSearchKey] = useState('');
const [showModal, setShowModal] = useState(false);
const [editingItem, setEditingItem] = useState<MailFormItem | null>(null);
const [formData, setFormData] = useState<Partial<MailFormItem>>(initialFormData);
const [saving, setSaving] = useState(false);
const loadData = useCallback(async () => {
setLoading(true);
try {
const response = await comms.getMailFormList();
if (response.Success && response.Data) {
setMailForms(response.Data);
}
} catch (error) {
console.error('데이터 로드 오류:', error);
alert('데이터를 불러오는 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
const filteredItems = mailForms.filter(item =>
!searchKey ||
item.title?.toLowerCase().includes(searchKey.toLowerCase()) ||
item.cate?.toLowerCase().includes(searchKey.toLowerCase()) ||
item.subject?.toLowerCase().includes(searchKey.toLowerCase())
);
const openAddModal = () => {
setEditingItem(null);
setFormData(initialFormData);
setShowModal(true);
};
const openEditModal = (item: MailFormItem) => {
setEditingItem(item);
setFormData({
cate: item.cate || '',
title: item.title || '',
tolist: item.tolist || '',
bcc: item.bcc || '',
cc: item.cc || '',
subject: item.subject || '',
tail: item.tail || '',
body: item.body || '',
selfTo: item.selfTo || false,
selfCC: item.selfCC || false,
selfBCC: item.selfBCC || false,
exceptmail: item.exceptmail || '',
exceptmailcc: item.exceptmailcc || '',
});
setShowModal(true);
};
const handleSave = async () => {
if (!formData.title?.trim()) {
alert('양식명을 입력해주세요.');
return;
}
setSaving(true);
try {
let response;
if (editingItem) {
response = await comms.editMailForm(
editingItem.idx,
formData.cate || '',
formData.title || '',
formData.tolist || '',
formData.bcc || '',
formData.cc || '',
formData.subject || '',
formData.tail || '',
formData.body || '',
formData.selfTo || false,
formData.selfCC || false,
formData.selfBCC || false,
formData.exceptmail || '',
formData.exceptmailcc || ''
);
} else {
response = await comms.addMailForm(
formData.cate || '',
formData.title || '',
formData.tolist || '',
formData.bcc || '',
formData.cc || '',
formData.subject || '',
formData.tail || '',
formData.body || '',
formData.selfTo || false,
formData.selfCC || false,
formData.selfBCC || false,
formData.exceptmail || '',
formData.exceptmailcc || ''
);
}
if (response.Success) {
setShowModal(false);
loadData();
} else {
alert(response.Message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('저장 오류:', error);
alert('저장 중 오류가 발생했습니다.');
} finally {
setSaving(false);
}
};
const handleDelete = async (item: MailFormItem) => {
if (!confirm(`"${item.title}" 양식을 삭제하시겠습니까?`)) return;
try {
const response = await comms.deleteMailForm(item.idx);
if (response.Success) {
loadData();
} else {
alert(response.Message || '삭제에 실패했습니다.');
}
} catch (error) {
console.error('삭제 오류:', error);
alert('삭제 중 오류가 발생했습니다.');
}
};
return (
<div className="space-y-6">
{/* 헤더 */}
<div className="glass-effect rounded-2xl p-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div className="flex items-center space-x-3">
<div className="p-3 bg-primary-500/20 rounded-xl">
<Mail className="w-6 h-6 text-primary-400" />
</div>
<div>
<h1 className="text-2xl font-bold text-white"></h1>
<p className="text-white/60 text-sm"> 릿 </p>
</div>
</div>
<div className="flex items-center space-x-3">
{/* 검색 */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-white/40" />
<input
type="text"
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
placeholder="검색..."
className="pl-10 pr-4 py-2 bg-white/10 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500 w-48"
/>
</div>
<button
onClick={loadData}
disabled={loading}
className="flex items-center space-x-2 px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
<button
onClick={openAddModal}
className="flex items-center space-x-2 px-4 py-2 bg-primary-500 hover:bg-primary-600 rounded-lg text-white transition-colors"
>
<Plus className="w-4 h-4" />
<span> </span>
</button>
</div>
</div>
</div>
{/* 목록 */}
<div className="glass-effect rounded-2xl overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 text-white animate-spin" />
</div>
) : filteredItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-white/50">
<Mail className="w-12 h-12 mb-4 opacity-50" />
<p> .</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-white/10">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase w-24"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-20">To</th>
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-20">CC</th>
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-20">BCC</th>
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-24"></th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{filteredItems.map((item) => (
<tr key={item.idx} className="hover:bg-white/5 transition-colors">
<td className="px-4 py-3 text-white/70 text-sm">{item.cate || '-'}</td>
<td className="px-4 py-3 text-white text-sm font-medium">{item.title}</td>
<td className="px-4 py-3 text-white/70 text-sm">{item.subject || '-'}</td>
<td className="px-4 py-3 text-center">
{item.selfTo && (
<span className="inline-block w-5 h-5 bg-success-500/20 text-success-400 rounded text-xs leading-5">S</span>
)}
</td>
<td className="px-4 py-3 text-center">
{item.selfCC && (
<span className="inline-block w-5 h-5 bg-warning-500/20 text-warning-400 rounded text-xs leading-5">S</span>
)}
</td>
<td className="px-4 py-3 text-center">
{item.selfBCC && (
<span className="inline-block w-5 h-5 bg-primary-500/20 text-primary-400 rounded text-xs leading-5">S</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-center space-x-2">
<button
onClick={() => openEditModal(item)}
className="p-1.5 hover:bg-white/10 rounded text-white/70 hover:text-white transition-colors"
title="수정"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(item)}
className="p-1.5 hover:bg-danger-500/20 rounded text-white/70 hover:text-danger-400 transition-colors"
title="삭제"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* 편집 모달 */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-slate-800 rounded-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
{/* 모달 헤더 */}
<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={() => setShowModal(false)}
className="p-2 hover:bg-white/10 rounded-lg text-white/70 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* 모달 내용 */}
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{/* 1행: 분류, 양식명 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-white/70 text-sm mb-1"></label>
<input
type="text"
value={formData.cate || ''}
onChange={(e) => setFormData({ ...formData, cate: e.target.value })}
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500"
/>
</div>
<div>
<label className="block text-white/70 text-sm mb-1"> *</label>
<input
type="text"
value={formData.title || ''}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500"
/>
</div>
</div>
{/* 2행: 제목 */}
<div>
<label className="block text-white/70 text-sm mb-1"> </label>
<input
type="text"
value={formData.subject || ''}
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500"
/>
</div>
{/* 3행: 수신자 */}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-white/70 text-sm mb-1">To ()</label>
<textarea
value={formData.tolist || ''}
onChange={(e) => setFormData({ ...formData, tolist: e.target.value })}
rows={2}
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500 resize-none"
placeholder="이메일 주소 (줄바꿈으로 구분)"
/>
<label className="flex items-center mt-1 text-sm text-white/60">
<input
type="checkbox"
checked={formData.selfTo || false}
onChange={(e) => setFormData({ ...formData, selfTo: e.target.checked })}
className="mr-2"
/>
</label>
</div>
<div>
<label className="block text-white/70 text-sm mb-1">CC ()</label>
<textarea
value={formData.cc || ''}
onChange={(e) => setFormData({ ...formData, cc: e.target.value })}
rows={2}
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500 resize-none"
placeholder="이메일 주소 (줄바꿈으로 구분)"
/>
<label className="flex items-center mt-1 text-sm text-white/60">
<input
type="checkbox"
checked={formData.selfCC || false}
onChange={(e) => setFormData({ ...formData, selfCC: e.target.checked })}
className="mr-2"
/>
</label>
</div>
<div>
<label className="block text-white/70 text-sm mb-1">BCC ()</label>
<textarea
value={formData.bcc || ''}
onChange={(e) => setFormData({ ...formData, bcc: e.target.value })}
rows={2}
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500 resize-none"
placeholder="이메일 주소 (줄바꿈으로 구분)"
/>
<label className="flex items-center mt-1 text-sm text-white/60">
<input
type="checkbox"
checked={formData.selfBCC || false}
onChange={(e) => setFormData({ ...formData, selfBCC: e.target.checked })}
className="mr-2"
/>
</label>
</div>
</div>
{/* 4행: 제외 메일 */}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-white/70 text-sm mb-1">To </label>
<input
type="text"
value={formData.exceptmail || ''}
onChange={(e) => setFormData({ ...formData, exceptmail: e.target.value })}
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500"
placeholder="제외할 이메일 주소"
/>
</div>
<div>
<label className="block text-white/70 text-sm mb-1">CC </label>
<input
type="text"
value={formData.exceptmailcc || ''}
onChange={(e) => setFormData({ ...formData, exceptmailcc: e.target.value })}
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500"
placeholder="제외할 이메일 주소"
/>
</div>
</div>
{/* 5행: 본문 */}
<div>
<label className="block text-white/70 text-sm mb-1"> </label>
<textarea
value={formData.body || ''}
onChange={(e) => setFormData({ ...formData, body: e.target.value })}
rows={6}
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500 resize-none"
placeholder="메일 본문 내용..."
/>
</div>
{/* 6행: 꼬리말 */}
<div>
<label className="block text-white/70 text-sm mb-1"></label>
<textarea
value={formData.tail || ''}
onChange={(e) => setFormData({ ...formData, tail: e.target.value })}
rows={3}
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500 resize-none"
placeholder="메일 꼬리말..."
/>
</div>
</div>
{/* 모달 푸터 */}
<div className="flex items-center justify-end space-x-3 px-6 py-4 border-t border-white/10">
<button
onClick={() => setShowModal(false)}
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors"
>
</button>
<button
onClick={handleSave}
disabled={saving}
className="flex items-center space-x-2 px-4 py-2 bg-primary-500 hover:bg-primary-600 rounded-lg text-white transition-colors disabled:opacity-50"
>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
<span></span>
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,248 @@
import { useState, useEffect, useCallback } from 'react';
import {
Calendar,
Save,
RefreshCw,
ChevronLeft,
ChevronRight,
Loader2,
} from 'lucide-react';
import { comms } from '@/communication';
import { HolidayItem } from '@/types';
interface DayInfo extends HolidayItem {
dayOfWeek: number;
dayName: string;
}
export function MonthlyWorkPage() {
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [month, setMonth] = useState(() => {
const now = new Date();
return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
});
const [holidays, setHolidays] = useState<DayInfo[]>([]);
const [hasChanges, setHasChanges] = useState(false);
const dayNames = ['일', '월', '화', '수', '목', '금', '토'];
const loadData = useCallback(async () => {
setLoading(true);
try {
// 먼저 초기화 시도 (데이터 없으면 생성)
await comms.initializeHoliday(month);
// 데이터 로드
const response = await comms.getHolidayList(month);
if (response.Success && response.Data) {
const data = response.Data.map(item => {
const date = new Date(item.pdate);
return {
...item,
dayOfWeek: date.getDay(),
dayName: dayNames[date.getDay()],
};
});
setHolidays(data);
setHasChanges(false);
}
} catch (error) {
console.error('데이터 로드 오류:', error);
alert('데이터를 불러오는 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
}, [month]);
useEffect(() => {
loadData();
}, [loadData]);
const handleMonthChange = (delta: number) => {
const [year, mon] = month.split('-').map(Number);
const newDate = new Date(year, mon - 1 + delta, 1);
setMonth(`${newDate.getFullYear()}-${String(newDate.getMonth() + 1).padStart(2, '0')}`);
};
const handleToggleFree = (idx: number) => {
setHolidays(prev => prev.map(h =>
h.idx === idx ? { ...h, free: !h.free } : h
));
setHasChanges(true);
};
const handleMemoChange = (idx: number, memo: string) => {
setHolidays(prev => prev.map(h =>
h.idx === idx ? { ...h, memo } : h
));
setHasChanges(true);
};
const handleSave = async () => {
setSaving(true);
try {
const dataToSave = holidays.map(({ idx, pdate, free, memo }) => ({
idx, pdate, free, memo
}));
const response = await comms.saveHolidays(month, dataToSave as HolidayItem[]);
if (response.Success) {
setHasChanges(false);
} else {
alert(response.Message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('저장 오류:', error);
alert('저장 중 오류가 발생했습니다.');
} finally {
setSaving(false);
}
};
// 통계 계산
const workDays = holidays.filter(h => !h.free).length;
const freeDays = holidays.filter(h => h.free).length;
return (
<div className="space-y-6">
{/* 헤더 */}
<div className="glass-effect rounded-2xl p-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div className="flex items-center space-x-3">
<div className="p-3 bg-primary-500/20 rounded-xl">
<Calendar className="w-6 h-6 text-primary-400" />
</div>
<div>
<h1 className="text-2xl font-bold text-white"></h1>
<p className="text-white/60 text-sm"> </p>
</div>
</div>
<div className="flex items-center space-x-4">
{/* 월 선택 */}
<div className="flex items-center space-x-2 bg-white/10 rounded-lg px-3 py-2">
<button
onClick={() => handleMonthChange(-1)}
className="p-1 hover:bg-white/10 rounded transition-colors"
>
<ChevronLeft className="w-5 h-5 text-white" />
</button>
<input
type="month"
value={month}
onChange={(e) => setMonth(e.target.value)}
className="bg-transparent text-white text-center w-32 focus:outline-none"
/>
<button
onClick={() => handleMonthChange(1)}
className="p-1 hover:bg-white/10 rounded transition-colors"
>
<ChevronRight className="w-5 h-5 text-white" />
</button>
</div>
{/* 버튼들 */}
<button
onClick={loadData}
disabled={loading}
className="flex items-center space-x-2 px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
<span className="hidden sm:inline"></span>
</button>
<button
onClick={handleSave}
disabled={saving || !hasChanges}
className="flex items-center space-x-2 px-4 py-2 bg-primary-500 hover:bg-primary-600 rounded-lg text-white transition-colors disabled:opacity-50"
>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
<span></span>
</button>
</div>
</div>
{/* 통계 */}
<div className="mt-4 flex items-center space-x-6 text-sm">
<span className="text-white/70">
: <span className="text-white font-semibold">{workDays}</span>
</span>
<span className="text-white/70">
: <span className="text-danger-400 font-semibold">{freeDays}</span>
</span>
<span className="text-white/70">
: <span className="text-white font-semibold">{holidays.length}</span>
</span>
</div>
</div>
{/* 테이블 */}
<div className="glass-effect rounded-2xl overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 text-white animate-spin" />
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-white/10">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase w-32"></th>
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-20"></th>
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-24"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{holidays.map((day) => (
<tr
key={day.idx}
className={`hover:bg-white/5 transition-colors ${
day.dayOfWeek === 0 ? 'bg-danger-500/10' :
day.dayOfWeek === 6 ? 'bg-primary-500/10' : ''
}`}
>
<td className="px-4 py-3 text-white text-sm">
{day.pdate}
</td>
<td className={`px-4 py-3 text-center text-sm font-medium ${
day.dayOfWeek === 0 ? 'text-danger-400' :
day.dayOfWeek === 6 ? 'text-primary-400' : 'text-white/70'
}`}>
{day.dayName}
</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => handleToggleFree(day.idx)}
className={`w-8 h-8 rounded-lg transition-colors ${
day.free
? 'bg-danger-500/20 text-danger-400 hover:bg-danger-500/30'
: 'bg-white/10 text-white/40 hover:bg-white/20'
}`}
>
{day.free ? 'O' : '-'}
</button>
</td>
<td className="px-4 py-3">
<input
type="text"
value={day.memo || ''}
onChange={(e) => handleMemoChange(day.idx, e.target.value)}
placeholder="메모 입력..."
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-primary-500"
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import { Construction } from 'lucide-react';
interface PlaceholderPageProps {
title: string;
}
export function PlaceholderPage({ title }: PlaceholderPageProps) {
return (
<div className="flex flex-col items-center justify-center h-full animate-fade-in">
<div className="glass-effect rounded-2xl p-12 text-center">
<Construction className="w-16 h-16 text-warning-400 mx-auto mb-4" />
<h2 className="text-2xl font-bold text-white mb-2">{title}</h2>
<p className="text-white/60">
.
</p>
<p className="text-white/40 text-sm mt-2">
React .
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,719 @@
import { useState, useEffect, useCallback } from 'react';
import {
Plus,
Edit2,
Trash2,
Flag,
Zap,
CheckCircle,
X,
Loader2,
} from 'lucide-react';
import { comms } from '@/communication';
import { TodoModel, TodoStatus, TodoPriority } from '@/types';
// 상태/중요도 유틸리티 함수들
const getStatusText = (status: string): string => {
switch (status) {
case '0': return '대기';
case '1': return '진행';
case '2': return '취소';
case '3': return '보류';
case '5': return '완료';
default: return '대기';
}
};
const getStatusClass = (status: string): string => {
switch (status) {
case '0': return 'bg-gray-500/20 text-gray-300 border-gray-500/30';
case '1': return 'bg-primary-500/20 text-primary-300 border-primary-500/30';
case '2': return 'bg-danger-500/20 text-danger-300 border-danger-500/30';
case '3': return 'bg-warning-500/20 text-warning-300 border-warning-500/30';
case '5': return 'bg-success-500/20 text-success-300 border-success-500/30';
default: return 'bg-white/10 text-white/50 border-white/20';
}
};
const getPriorityText = (seqno: number): string => {
switch (seqno) {
case 1: return '중요';
case 2: return '매우 중요';
case 3: return '긴급';
default: return '보통';
}
};
const getPriorityClass = (seqno: number): string => {
switch (seqno) {
case 1: return 'bg-primary-500/20 text-primary-300';
case 2: return 'bg-warning-500/20 text-warning-300';
case 3: return 'bg-danger-500/20 text-danger-300';
default: return 'bg-white/10 text-white/50';
}
};
// 폼 데이터 타입
interface TodoFormData {
title: string;
remark: string;
expire: string;
seqno: TodoPriority;
flag: boolean;
request: string;
status: TodoStatus;
}
const initialFormData: TodoFormData = {
title: '',
remark: '',
expire: '',
seqno: 0,
flag: false,
request: '',
status: '0',
};
export function Todo() {
const [todos, setTodos] = useState<TodoModel[]>([]);
const [loading, setLoading] = useState(true);
const [processing, setProcessing] = useState(false);
const [activeTab, setActiveTab] = useState<'active' | 'hold' | 'completed' | 'cancelled'>('active');
// 모달 상태
const [showAddModal, setShowAddModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [editingTodo, setEditingTodo] = useState<TodoModel | null>(null);
const [formData, setFormData] = useState<TodoFormData>(initialFormData);
// 할일 목록 로드
const loadTodos = useCallback(async () => {
try {
const response = await comms.getTodos();
if (response.Success && response.Data) {
setTodos(response.Data);
}
} catch (error) {
console.error('할일 목록 로드 오류:', error);
alert('할일 목록을 불러오는 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadTodos();
}, [loadTodos]);
// 필터링된 할일 목록
const activeTodos = todos.filter(todo => todo.status === '0' || todo.status === '1'); // 대기 + 진행
const holdTodos = todos.filter(todo => todo.status === '3'); // 보류
const completedTodos = todos.filter(todo => todo.status === '5'); // 완료
const cancelledTodos = todos.filter(todo => todo.status === '2'); // 취소
// 새 할일 추가
const handleAdd = async () => {
if (!formData.remark.trim()) {
alert('할일 내용을 입력해주세요.');
return;
}
setProcessing(true);
try {
const response = await comms.createTodo(
formData.title,
formData.remark,
formData.expire || null,
formData.seqno,
formData.flag,
formData.request || null,
formData.status
);
if (response.Success) {
setShowAddModal(false);
setFormData(initialFormData);
loadTodos();
} else {
alert(response.Message || '할일 추가에 실패했습니다.');
}
} catch (error) {
console.error('할일 추가 오류:', error);
alert('서버 연결에 실패했습니다.');
} finally {
setProcessing(false);
}
};
// 할일 수정 모달 열기
const openEditModal = async (todo: TodoModel) => {
try {
const response = await comms.getTodo(todo.idx);
if (response.Success && response.Data) {
const data = response.Data;
setEditingTodo(data);
setFormData({
title: data.title || '',
remark: data.remark || '',
expire: data.expire ? data.expire.split('T')[0] : '',
seqno: data.seqno as TodoPriority,
flag: data.flag || false,
request: data.request || '',
status: data.status as TodoStatus,
});
setShowEditModal(true);
}
} catch (error) {
console.error('할일 조회 오류:', error);
alert('할일 정보를 불러오는 중 오류가 발생했습니다.');
}
};
// 할일 수정
const handleUpdate = async () => {
if (!editingTodo || !formData.remark.trim()) {
alert('할일 내용을 입력해주세요.');
return;
}
setProcessing(true);
try {
const response = await comms.updateTodo(
editingTodo.idx,
formData.title,
formData.remark,
formData.expire || null,
formData.seqno,
formData.flag,
formData.request || null,
formData.status
);
if (response.Success) {
setShowEditModal(false);
setEditingTodo(null);
setFormData(initialFormData);
loadTodos();
} else {
alert(response.Message || '할일 수정에 실패했습니다.');
}
} catch (error) {
console.error('할일 수정 오류:', error);
alert('서버 연결에 실패했습니다.');
} finally {
setProcessing(false);
}
};
// 할일 삭제
const handleDelete = async (id: number) => {
if (!confirm('정말로 이 할일을 삭제하시겠습니까?')) {
return;
}
setProcessing(true);
try {
const response = await comms.deleteTodo(id);
if (response.Success) {
loadTodos();
} else {
alert(response.Message || '할일 삭제에 실패했습니다.');
}
} catch (error) {
console.error('할일 삭제 오류:', error);
alert('서버 연결에 실패했습니다.');
} finally {
setProcessing(false);
}
};
// 상태 빠른 변경
const handleQuickStatusChange = async (todo: TodoModel, newStatus: TodoStatus) => {
setProcessing(true);
try {
const response = await comms.updateTodo(
todo.idx,
todo.title,
todo.remark,
todo.expire,
todo.seqno,
todo.flag,
todo.request,
newStatus
);
if (response.Success) {
loadTodos();
} else {
alert(response.Message || '상태 변경에 실패했습니다.');
}
} catch (error) {
console.error('상태 변경 오류:', error);
} finally {
setProcessing(false);
}
};
if (loading) {
return (
<div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-white"></div>
</div>
);
}
return (
<div className="space-y-6 animate-fade-in">
{/* 헤더 */}
<div className="glass-effect rounded-2xl overflow-hidden">
<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">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" />
</svg>
</h2>
<button
onClick={() => {
setFormData(initialFormData);
setShowAddModal(true);
}}
className="bg-primary-500 hover:bg-primary-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center text-sm"
>
<Plus className="w-4 h-4 mr-1" />
</button>
</div>
{/* 탭 메뉴 */}
<div className="px-6 py-2 border-b border-white/10">
<div className="flex space-x-1 bg-white/5 rounded-lg p-1">
<button
onClick={() => setActiveTab('active')}
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
activeTab === 'active'
? 'text-white bg-white/20 shadow-sm'
: 'text-white/60 hover:text-white hover:bg-white/10'
}`}
>
<div className="flex items-center justify-center space-x-2">
<Zap className="w-4 h-4" />
<span></span>
<span className="px-2 py-0.5 text-xs bg-primary-500/30 text-primary-200 rounded-full">
{activeTodos.length}
</span>
</div>
</button>
<button
onClick={() => setActiveTab('hold')}
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
activeTab === 'hold'
? 'text-white bg-white/20 shadow-sm'
: 'text-white/60 hover:text-white hover:bg-white/10'
}`}
>
<div className="flex items-center justify-center space-x-2">
<span></span>
<span className="px-2 py-0.5 text-xs bg-warning-500/30 text-warning-200 rounded-full">
{holdTodos.length}
</span>
</div>
</button>
<button
onClick={() => setActiveTab('completed')}
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
activeTab === 'completed'
? 'text-white bg-white/20 shadow-sm'
: 'text-white/60 hover:text-white hover:bg-white/10'
}`}
>
<div className="flex items-center justify-center space-x-2">
<CheckCircle className="w-4 h-4" />
<span></span>
<span className="px-2 py-0.5 text-xs bg-success-500/30 text-success-200 rounded-full">
{completedTodos.length}
</span>
</div>
</button>
<button
onClick={() => setActiveTab('cancelled')}
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
activeTab === 'cancelled'
? 'text-white bg-white/20 shadow-sm'
: 'text-white/60 hover:text-white hover:bg-white/10'
}`}
>
<div className="flex items-center justify-center space-x-2">
<X className="w-4 h-4" />
<span></span>
<span className="px-2 py-0.5 text-xs bg-danger-500/30 text-danger-200 rounded-full">
{cancelledTodos.length}
</span>
</div>
</button>
</div>
</div>
{/* 할일 테이블 */}
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-white/10">
<tr>
{activeTab === 'active' && (
<th className="px-2 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider w-10 border-r border-white/10"></th>
)}
<th className="px-3 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider w-16 border-r border-white/10"></th>
<th className="px-3 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider w-16 border-r border-white/10"></th>
<th className="px-4 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider border-r border-white/10"></th>
<th className="px-3 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider w-24 border-r border-white/10"></th>
<th className="px-3 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider w-20 border-r border-white/10"></th>
<th className="px-3 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider w-24">
{activeTab === 'completed' ? '완료일' : '만료일'}
</th>
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{(activeTab === 'active' ? activeTodos : activeTab === 'hold' ? holdTodos : activeTab === 'completed' ? completedTodos : cancelledTodos).map((todo) => (
<TodoRow
key={todo.idx}
todo={todo}
showOkdate={activeTab === 'completed'}
showCompleteButton={activeTab === 'active'}
onEdit={() => openEditModal(todo)}
onComplete={() => handleQuickStatusChange(todo, '5')}
/>
))}
{(activeTab === 'active' ? activeTodos : activeTab === 'hold' ? holdTodos : activeTab === 'completed' ? completedTodos : cancelledTodos).length === 0 && (
<tr>
<td colSpan={activeTab === 'active' ? 7 : 6} className="px-6 py-8 text-center text-white/50">
{activeTab === 'active' ? '진행중인 할일이 없습니다' : activeTab === 'hold' ? '보류된 할일이 없습니다' : activeTab === 'completed' ? '완료된 할일이 없습니다' : '취소된 할일이 없습니다'}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* 로딩 인디케이터 */}
{processing && (
<div className="fixed top-4 right-4 bg-white/20 backdrop-blur-sm rounded-full px-4 py-2 text-white text-sm flex items-center">
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</div>
)}
{/* 추가 모달 */}
{showAddModal && (
<TodoModal
title="새 할일 추가"
formData={formData}
setFormData={setFormData}
onSubmit={handleAdd}
onClose={() => setShowAddModal(false)}
submitText="추가"
processing={processing}
/>
)}
{/* 수정 모달 */}
{showEditModal && editingTodo && (
<TodoModal
title="할일 수정"
formData={formData}
setFormData={setFormData}
onSubmit={handleUpdate}
onClose={() => {
setShowEditModal(false);
setEditingTodo(null);
}}
submitText="수정"
processing={processing}
isEdit={true}
onComplete={() => {
handleQuickStatusChange(editingTodo, '5');
setShowEditModal(false);
setEditingTodo(null);
}}
onDelete={() => {
handleDelete(editingTodo.idx);
setShowEditModal(false);
setEditingTodo(null);
}}
currentStatus={editingTodo.status}
/>
)}
</div>
);
}
// 할일 행 컴포넌트
interface TodoRowProps {
todo: TodoModel;
showOkdate: boolean;
showCompleteButton?: boolean;
onEdit: () => void;
onComplete: () => void;
}
function TodoRow({ todo, showOkdate, showCompleteButton = true, onEdit, onComplete }: TodoRowProps) {
const isExpired = todo.expire && new Date(todo.expire) < new Date();
const handleComplete = (e: React.MouseEvent) => {
e.stopPropagation();
if (confirm('이 할일을 완료 처리하시겠습니까?')) {
onComplete();
}
};
return (
<tr
className="hover:bg-white/5 transition-colors cursor-pointer"
onClick={onEdit}
>
{showCompleteButton && (
<td className="px-2 py-4 text-center border-r border-white/10">
<button
onClick={handleComplete}
className="p-1.5 bg-success-500/20 hover:bg-success-500/40 text-success-300 rounded-full transition-colors"
title="완료 처리"
>
<CheckCircle className="w-4 h-4" />
</button>
</td>
)}
<td className="px-3 py-4 text-center whitespace-nowrap border-r border-white/10">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${getStatusClass(todo.status)}`}>
{getStatusText(todo.status)}
</span>
</td>
<td className="px-3 py-4 text-center whitespace-nowrap border-r border-white/10">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
todo.flag ? 'bg-warning-500/20 text-warning-300' : 'bg-white/10 text-white/50'
}`}>
{todo.flag ? <Flag className="w-3 h-3 mr-1" /> : null}
{todo.flag ? '고정' : '일반'}
</span>
</td>
<td className="px-4 py-4 text-left text-white border-r border-white/10">{todo.title || '제목 없음'}</td>
<td className="px-3 py-4 text-center text-white/80 border-r border-white/10">{todo.request || '-'}</td>
<td className="px-3 py-4 text-center whitespace-nowrap border-r border-white/10">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${getPriorityClass(todo.seqno)}`}>
{getPriorityText(todo.seqno)}
</span>
</td>
<td className={`px-3 py-4 text-center whitespace-nowrap ${showOkdate ? 'text-success-400' : (isExpired ? 'text-danger-400' : 'text-white/80')}`}>
{showOkdate
? (todo.okdate ? new Date(todo.okdate).toLocaleDateString('ko-KR', { year: '2-digit', month: '2-digit', day: '2-digit' }) : '-')
: (todo.expire ? new Date(todo.expire).toLocaleDateString('ko-KR', { year: '2-digit', month: '2-digit', day: '2-digit' }) : '-')
}
</td>
</tr>
);
}
// 할일 모달 컴포넌트
interface TodoModalProps {
title: string;
formData: TodoFormData;
setFormData: React.Dispatch<React.SetStateAction<TodoFormData>>;
onSubmit: () => void;
onClose: () => void;
submitText: string;
processing: boolean;
isEdit?: boolean;
onComplete?: () => void;
onDelete?: () => void;
currentStatus?: string;
}
function TodoModal({
title,
formData,
setFormData,
onSubmit,
onClose,
submitText,
processing,
isEdit = false,
onComplete,
onDelete,
currentStatus,
}: TodoModalProps) {
const statusOptions: { value: TodoStatus; label: string }[] = [
{ value: '0', label: '대기' },
{ value: '1', label: '진행' },
{ value: '3', label: '보류' },
{ value: '2', label: '취소' },
{ value: '5', label: '완료' },
];
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50" onClick={onClose}>
<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" />
{title}
</h2>
<button onClick={onClose} 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={formData.title}
onChange={(e) => setFormData(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={formData.expire}
onChange={(e) => setFormData(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={formData.remark}
onChange={(e) => setFormData(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={formData.request}
onChange={(e) => setFormData(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">
{statusOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setFormData(prev => ({ ...prev, status: option.value }))}
className={`px-3 py-1 rounded-lg text-xs font-medium border transition-all ${
formData.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={formData.seqno}
onChange={(e) => setFormData(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={formData.flag}
onChange={(e) => setFormData(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>
{isEdit && onDelete && (
<button
type="button"
onClick={onDelete}
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={onClose}
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors"
>
</button>
{isEdit && onComplete && currentStatus !== '5' && (
<button
type="button"
onClick={onComplete}
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={onSubmit}
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" />
)}
{submitText}
</button>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,533 @@
import { useState, useEffect, useCallback } from 'react';
import {
Users,
Plus,
Edit2,
Trash2,
Save,
X,
Loader2,
RefreshCw,
Search,
Shield,
Check,
} from 'lucide-react';
import { comms } from '@/communication';
import { UserGroupItem, PermissionInfo } from '@/types';
const initialFormData: Partial<UserGroupItem> = {
dept: '',
path_kj: '',
permission: 0,
advpurchase: false,
advkisul: false,
managerinfo: '',
devinfo: '',
usemail: false,
};
// 비트 연산 헬퍼 함수
const getBit = (value: number, index: number): boolean => {
return ((value >> index) & 1) === 1;
};
const setBit = (value: number, index: number, flag: boolean): number => {
if (flag) {
return value | (1 << index);
} else {
return value & ~(1 << index);
}
};
export function UserGroupPage() {
const [loading, setLoading] = useState(false);
const [groups, setGroups] = useState<UserGroupItem[]>([]);
const [searchKey, setSearchKey] = useState('');
const [showModal, setShowModal] = useState(false);
const [showPermissionModal, setShowPermissionModal] = useState(false);
const [editingItem, setEditingItem] = useState<UserGroupItem | null>(null);
const [formData, setFormData] = useState<Partial<UserGroupItem>>(initialFormData);
const [permissionInfo, setPermissionInfo] = useState<PermissionInfo[]>([]);
const [saving, setSaving] = useState(false);
const loadData = useCallback(async () => {
setLoading(true);
try {
const [groupsRes, permRes] = await Promise.all([
comms.getUserGroupList(),
comms.getPermissionInfo()
]);
if (groupsRes.Success && groupsRes.Data) {
setGroups(groupsRes.Data);
}
if (permRes.Success && permRes.Data) {
setPermissionInfo(permRes.Data);
}
} catch (error) {
console.error('데이터 로드 오류:', error);
alert('데이터를 불러오는 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadData();
}, [loadData]);
const filteredItems = groups.filter(item =>
!searchKey ||
item.dept?.toLowerCase().includes(searchKey.toLowerCase()) ||
item.managerinfo?.toLowerCase().includes(searchKey.toLowerCase())
);
const openAddModal = () => {
setEditingItem(null);
setFormData(initialFormData);
setShowModal(true);
};
const openEditModal = (item: UserGroupItem) => {
setEditingItem(item);
setFormData({
dept: item.dept || '',
path_kj: item.path_kj || '',
permission: item.permission || 0,
advpurchase: item.advpurchase || false,
advkisul: item.advkisul || false,
managerinfo: item.managerinfo || '',
devinfo: item.devinfo || '',
usemail: item.usemail || false,
});
setShowModal(true);
};
const openPermissionModal = (item: UserGroupItem) => {
setEditingItem(item);
setFormData({
...formData,
dept: item.dept,
permission: item.permission || 0,
});
setShowPermissionModal(true);
};
const handlePermissionChange = (index: number, checked: boolean) => {
const newPermission = setBit(formData.permission || 0, index, checked);
setFormData({ ...formData, permission: newPermission });
};
const handleSavePermission = async () => {
if (!editingItem) return;
setSaving(true);
try {
const response = await comms.editUserGroup(
editingItem.dept,
editingItem.dept,
editingItem.path_kj || '',
formData.permission || 0,
editingItem.advpurchase || false,
editingItem.advkisul || false,
editingItem.managerinfo || '',
editingItem.devinfo || '',
editingItem.usemail || false
);
if (response.Success) {
setShowPermissionModal(false);
loadData();
} else {
alert(response.Message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('저장 오류:', error);
alert('저장 중 오류가 발생했습니다.');
} finally {
setSaving(false);
}
};
const handleSave = async () => {
if (!formData.dept?.trim()) {
alert('부서명을 입력해주세요.');
return;
}
setSaving(true);
try {
let response;
if (editingItem) {
response = await comms.editUserGroup(
editingItem.dept,
formData.dept || '',
formData.path_kj || '',
formData.permission || 0,
formData.advpurchase || false,
formData.advkisul || false,
formData.managerinfo || '',
formData.devinfo || '',
formData.usemail || false
);
} else {
response = await comms.addUserGroup(
formData.dept || '',
formData.path_kj || '',
formData.permission || 0,
formData.advpurchase || false,
formData.advkisul || false,
formData.managerinfo || '',
formData.devinfo || '',
formData.usemail || false
);
}
if (response.Success) {
setShowModal(false);
loadData();
} else {
alert(response.Message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('저장 오류:', error);
alert('저장 중 오류가 발생했습니다.');
} finally {
setSaving(false);
}
};
const handleDelete = async (item: UserGroupItem) => {
if (!confirm(`"${item.dept}" 그룹을 삭제하시겠습니까?`)) return;
try {
const response = await comms.deleteUserGroup(item.dept);
if (response.Success) {
loadData();
} else {
alert(response.Message || '삭제에 실패했습니다.');
}
} catch (error) {
console.error('삭제 오류:', error);
alert('삭제 중 오류가 발생했습니다.');
}
};
// 권한 카운트 계산
const getPermissionCount = (permission: number): number => {
let count = 0;
for (let i = 0; i < 11; i++) {
if (getBit(permission, i)) count++;
}
return count;
};
return (
<div className="space-y-6">
{/* 헤더 */}
<div className="glass-effect rounded-2xl p-6">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div className="flex items-center space-x-3">
<div className="p-3 bg-primary-500/20 rounded-xl">
<Users className="w-6 h-6 text-primary-400" />
</div>
<div>
<h1 className="text-2xl font-bold text-white"></h1>
<p className="text-white/60 text-sm">/ </p>
</div>
</div>
<div className="flex items-center space-x-3">
{/* 검색 */}
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-white/40" />
<input
type="text"
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
placeholder="부서명 검색..."
className="pl-10 pr-4 py-2 bg-white/10 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500 w-48"
/>
</div>
<button
onClick={loadData}
disabled={loading}
className="flex items-center space-x-2 px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors disabled:opacity-50"
>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
<button
onClick={openAddModal}
className="flex items-center space-x-2 px-4 py-2 bg-primary-500 hover:bg-primary-600 rounded-lg text-white transition-colors"
>
<Plus className="w-4 h-4" />
<span> </span>
</button>
</div>
</div>
</div>
{/* 목록 */}
<div className="glass-effect rounded-2xl overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 text-white animate-spin" />
</div>
) : filteredItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-white/50">
<Users className="w-12 h-12 mb-4 opacity-50" />
<p> .</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-white/10">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-24"></th>
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-20"></th>
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-20"></th>
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-20"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-28"></th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{filteredItems.map((item, index) => (
<tr key={`${item.dept}-${index}`} className="hover:bg-white/5 transition-colors">
<td className="px-4 py-3 text-white font-medium">{item.dept}</td>
<td className="px-4 py-3 text-white/70 text-sm">{item.path_kj || '-'}</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => openPermissionModal(item)}
className="inline-flex items-center space-x-1 px-2 py-1 bg-primary-500/20 text-primary-400 rounded text-xs hover:bg-primary-500/30 transition-colors"
>
<Shield className="w-3 h-3" />
<span>{getPermissionCount(item.permission || 0)}</span>
</button>
</td>
<td className="px-4 py-3 text-center">
{item.advpurchase && <Check className="w-4 h-4 text-success-400 mx-auto" />}
</td>
<td className="px-4 py-3 text-center">
{item.advkisul && <Check className="w-4 h-4 text-success-400 mx-auto" />}
</td>
<td className="px-4 py-3 text-center">
{item.usemail && <Check className="w-4 h-4 text-success-400 mx-auto" />}
</td>
<td className="px-4 py-3 text-white/70 text-sm truncate max-w-48">{item.managerinfo || '-'}</td>
<td className="px-4 py-3">
<div className="flex items-center justify-center space-x-2">
<button
onClick={() => openEditModal(item)}
className="p-1.5 hover:bg-white/10 rounded text-white/70 hover:text-white transition-colors"
title="수정"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(item)}
className="p-1.5 hover:bg-danger-500/20 rounded text-white/70 hover:text-danger-400 transition-colors"
title="삭제"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* 그룹 편집 모달 */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-slate-800 rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col">
{/* 모달 헤더 */}
<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={() => setShowModal(false)}
className="p-2 hover:bg-white/10 rounded-lg text-white/70 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* 모달 내용 */}
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{/* 부서명 */}
<div>
<label className="block text-white/70 text-sm mb-1"> *</label>
<input
type="text"
value={formData.dept || ''}
onChange={(e) => setFormData({ ...formData, dept: e.target.value })}
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500"
/>
</div>
{/* 경로 */}
<div>
<label className="block text-white/70 text-sm mb-1"> (path_kj)</label>
<input
type="text"
value={formData.path_kj || ''}
onChange={(e) => setFormData({ ...formData, path_kj: e.target.value })}
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500"
/>
</div>
{/* 체크박스들 */}
<div className="grid grid-cols-3 gap-4">
<label className="flex items-center space-x-2 text-white/70">
<input
type="checkbox"
checked={formData.advpurchase || false}
onChange={(e) => setFormData({ ...formData, advpurchase: e.target.checked })}
className="w-4 h-4 rounded"
/>
<span></span>
</label>
<label className="flex items-center space-x-2 text-white/70">
<input
type="checkbox"
checked={formData.advkisul || false}
onChange={(e) => setFormData({ ...formData, advkisul: e.target.checked })}
className="w-4 h-4 rounded"
/>
<span></span>
</label>
<label className="flex items-center space-x-2 text-white/70">
<input
type="checkbox"
checked={formData.usemail || false}
onChange={(e) => setFormData({ ...formData, usemail: e.target.checked })}
className="w-4 h-4 rounded"
/>
<span> </span>
</label>
</div>
{/* 관리자 정보 */}
<div>
<label className="block text-white/70 text-sm mb-1"> </label>
<textarea
value={formData.managerinfo || ''}
onChange={(e) => setFormData({ ...formData, managerinfo: e.target.value })}
rows={2}
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500 resize-none"
/>
</div>
{/* 개발자 정보 */}
<div>
<label className="block text-white/70 text-sm mb-1"> </label>
<textarea
value={formData.devinfo || ''}
onChange={(e) => setFormData({ ...formData, devinfo: e.target.value })}
rows={2}
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500 resize-none"
/>
</div>
</div>
{/* 모달 푸터 */}
<div className="flex items-center justify-end space-x-3 px-6 py-4 border-t border-white/10">
<button
onClick={() => setShowModal(false)}
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors"
>
</button>
<button
onClick={handleSave}
disabled={saving}
className="flex items-center space-x-2 px-4 py-2 bg-primary-500 hover:bg-primary-600 rounded-lg text-white transition-colors disabled:opacity-50"
>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
<span></span>
</button>
</div>
</div>
</div>
)}
{/* 권한 설정 모달 */}
{showPermissionModal && editingItem && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="bg-slate-800 rounded-2xl w-full max-w-md max-h-[90vh] overflow-hidden flex flex-col">
{/* 모달 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
<div>
<h2 className="text-xl font-bold text-white"> </h2>
<p className="text-white/60 text-sm">{editingItem.dept}</p>
</div>
<button
onClick={() => setShowPermissionModal(false)}
className="p-2 hover:bg-white/10 rounded-lg text-white/70 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* 모달 내용 */}
<div className="flex-1 overflow-y-auto p-6">
<div className="grid grid-cols-2 gap-3">
{permissionInfo.map((perm) => (
<label
key={perm.index}
className="flex items-center space-x-2 p-2 rounded-lg hover:bg-white/5 cursor-pointer"
title={perm.description}
>
<input
type="checkbox"
checked={getBit(formData.permission || 0, perm.index)}
onChange={(e) => handlePermissionChange(perm.index, e.target.checked)}
className="w-4 h-4 rounded"
/>
<span className="text-white/80 text-sm">{perm.label}</span>
</label>
))}
</div>
</div>
{/* 모달 푸터 */}
<div className="flex items-center justify-end space-x-3 px-6 py-4 border-t border-white/10">
<button
onClick={() => setShowPermissionModal(false)}
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors"
>
</button>
<button
onClick={handleSavePermission}
disabled={saving}
className="flex items-center space-x-2 px-4 py-2 bg-primary-500 hover:bg-primary-600 rounded-lg text-white transition-colors disabled:opacity-50"
>
{saving ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Save className="w-4 h-4" />
)}
<span></span>
</button>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,555 @@
import { useState, useEffect } from 'react';
import { Search, RefreshCw, Users, Check, X, User, Save } from 'lucide-react';
import { clsx } from 'clsx';
import { comms } from '@/communication';
import { GroupUser, UserLevelInfo, UserFullData } from '@/types';
// 사용자 상세 다이얼로그 Props
interface UserDetailDialogProps {
user: GroupUser;
levelInfo: UserLevelInfo | null;
onClose: () => void;
onSave: () => void;
}
// 사용자 상세 다이얼로그 컴포넌트
function UserDetailDialog({ user, levelInfo, onClose, onSave }: UserDetailDialogProps) {
const [formData, setFormData] = useState<UserFullData>({
id: user.id,
name: user.name || '',
nameE: user.nameE || '',
grade: user.grade || '',
email: user.email || '',
tel: user.tel || '',
hp: user.hp || '',
indate: user.indate || '',
outdate: user.outdate || '',
memo: user.memo || '',
processs: user.processs || '',
state: user.state || '',
level: user.level || 1,
useUserState: user.useUserState || false,
useJobReport: user.useJobReport || false,
exceptHoly: user.exceptHoly || false,
});
const [saving, setSaving] = useState(false);
// 편집 가능 여부: 관리자(level >= 5) 또는 본인
const isSelf = levelInfo?.CurrentUserId === user.id;
const canEdit = levelInfo?.CanEdit || isSelf;
const canEditAdmin = levelInfo?.CanEdit || false; // 관리자 전용 필드
const handleChange = (field: keyof UserFullData, value: string | number | boolean) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleSave = async () => {
if (!canEdit) return;
setSaving(true);
try {
const result = await comms.saveUserFull(formData);
if (result.Success) {
onSave();
onClose();
} else {
alert(result.Message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('저장 실패:', error);
alert('저장 중 오류가 발생했습니다.');
} finally {
setSaving(false);
}
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
<div
className="glass-effect rounded-xl w-full max-w-2xl max-h-[90vh] overflow-hidden"
onClick={(e) => e.stopPropagation()}
>
{/* 헤더 */}
<div className="p-4 border-b border-white/10 flex items-center justify-between">
<div className="flex items-center gap-2">
<User className="w-5 h-5 text-white/70" />
<h2 className="text-lg font-semibold text-white">
{canEdit ? '' : '(읽기 전용)'}
</h2>
</div>
<button
onClick={onClose}
className="text-white/60 hover:text-white transition-colors text-xl"
>
×
</button>
</div>
{/* 내용 */}
<div className="p-4 overflow-auto max-h-[calc(90vh-120px)]">
<div className="grid grid-cols-3 gap-4">
{/* 사번 (읽기 전용) */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="text"
value={formData.id}
disabled
className="w-full px-3 py-2 bg-white/5 border border-white/20 rounded-lg text-white/50"
/>
</div>
{/* 성명 */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="text"
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
disabled={!canEdit}
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
)}
/>
</div>
{/* 영문명 */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="text"
value={formData.nameE}
onChange={(e) => handleChange('nameE', e.target.value)}
disabled={!canEdit}
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
)}
/>
</div>
{/* 직책 */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="text"
value={formData.grade}
onChange={(e) => handleChange('grade', e.target.value)}
disabled={!canEdit}
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
)}
/>
</div>
{/* 공정 */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="text"
value={formData.processs}
onChange={(e) => handleChange('processs', e.target.value)}
disabled={!canEdit}
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
)}
/>
</div>
{/* 상태 */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="text"
value={formData.state}
onChange={(e) => handleChange('state', e.target.value)}
disabled={!canEdit}
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
)}
/>
</div>
{/* 이메일 */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="email"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
disabled={!canEdit}
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
)}
/>
</div>
{/* 전화 */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="text"
value={formData.tel}
onChange={(e) => handleChange('tel', e.target.value)}
disabled={!canEdit}
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
)}
/>
</div>
{/* 입사일 */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="text"
value={formData.indate}
onChange={(e) => handleChange('indate', e.target.value)}
disabled={!canEdit}
placeholder="YYYY-MM-DD"
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
)}
/>
</div>
{/* 퇴사일 */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="text"
value={formData.outdate}
onChange={(e) => handleChange('outdate', e.target.value)}
disabled={!canEdit}
placeholder="YYYY-MM-DD"
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
)}
/>
</div>
{/* 레벨 */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<select
value={formData.level}
onChange={(e) => handleChange('level', parseInt(e.target.value) || 0)}
disabled={!canEditAdmin}
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
canEditAdmin ? "bg-white/10" : "bg-white/5 text-white/50"
)}
>
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((lv) => (
<option key={lv} value={lv} className="bg-gray-800 text-white">
{lv}
</option>
))}
</select>
</div>
{/* 메모 */}
<div className="col-span-3">
<label className="block text-sm text-white/70 mb-1"></label>
<textarea
value={formData.memo}
onChange={(e) => handleChange('memo', e.target.value)}
disabled={!canEdit}
rows={2}
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white resize-none",
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
)}
/>
</div>
{/* 관리자 전용 설정 */}
<div className="col-span-3 border-t border-white/10 pt-4 mt-2">
<h3 className="text-sm font-medium text-white/80 mb-3">
{!canEditAdmin && '(관리자만 수정 가능)'}
</h3>
<div className="flex items-center gap-6">
{/* 계정 사용 */}
<label className={clsx(
"flex items-center gap-2 cursor-pointer",
!canEditAdmin && "opacity-50 cursor-not-allowed"
)}>
<input
type="checkbox"
checked={formData.useUserState}
onChange={(e) => handleChange('useUserState', e.target.checked)}
disabled={!canEditAdmin}
className="w-4 h-4 rounded"
/>
<span className="text-sm text-white"> </span>
</label>
{/* 일지 사용 */}
<label className={clsx(
"flex items-center gap-2 cursor-pointer",
!canEditAdmin && "opacity-50 cursor-not-allowed"
)}>
<input
type="checkbox"
checked={formData.useJobReport}
onChange={(e) => handleChange('useJobReport', e.target.checked)}
disabled={!canEditAdmin}
className="w-4 h-4 rounded"
/>
<span className="text-sm text-white"> </span>
</label>
{/* 휴가 제외 */}
<label className={clsx(
"flex items-center gap-2 cursor-pointer",
!canEditAdmin && "opacity-50 cursor-not-allowed"
)}>
<input
type="checkbox"
checked={formData.exceptHoly}
onChange={(e) => handleChange('exceptHoly', e.target.checked)}
disabled={!canEditAdmin}
className="w-4 h-4 rounded"
/>
<span className="text-sm text-white"> </span>
</label>
</div>
</div>
</div>
</div>
{/* 푸터 */}
<div className="p-4 border-t border-white/10 flex justify-end gap-2">
<button
onClick={onClose}
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors"
>
</button>
{canEdit && (
<button
onClick={handleSave}
disabled={saving}
className="flex items-center gap-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg text-white transition-colors"
>
<Save className="w-4 h-4" />
{saving ? '저장 중...' : '저장'}
</button>
)}
</div>
</div>
</div>
);
}
export function UserListPage() {
const [users, setUsers] = useState<GroupUser[]>([]);
const [process, setProcess] = useState('%');
const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState('');
const [levelInfo, setLevelInfo] = useState<UserLevelInfo | null>(null);
const [selectedUser, setSelectedUser] = useState<GroupUser | null>(null);
useEffect(() => {
loadLevelInfo();
loadUsers();
}, []);
const loadLevelInfo = async () => {
try {
const result = await comms.getCurrentUserLevel();
if (result.Success && result.Data) {
setLevelInfo(result.Data);
}
} catch (error) {
console.error('권한 정보 로드 실패:', error);
}
};
const loadUsers = async () => {
setLoading(true);
try {
const result = await comms.getUserList(process);
if (Array.isArray(result)) {
setUsers(result);
} else if (result && typeof result === 'object') {
const r = result as { Success?: boolean; Message?: string };
if (r.Success === false) {
console.error('사용자 목록 조회 실패:', r.Message);
}
setUsers([]);
} else {
console.error('사용자 목록 응답이 배열이 아님:', result);
setUsers([]);
}
} catch (error) {
console.error('사용자 목록 로드 실패:', error);
setUsers([]);
} finally {
setLoading(false);
}
};
const handleRefresh = () => {
loadUsers();
};
const handleRowClick = (user: GroupUser) => {
setSelectedUser(user);
};
const filteredUsers = users.filter(
(u) =>
(u.id ?? '').toLowerCase().includes(filter.toLowerCase()) ||
(u.name ?? '').toLowerCase().includes(filter.toLowerCase()) ||
(u.email ?? '').toLowerCase().includes(filter.toLowerCase()) ||
(u.tel ?? '').toLowerCase().includes(filter.toLowerCase())
);
return (
<div className="h-full flex flex-col">
{/* 헤더 */}
<div className="glass-effect rounded-xl p-4 mb-4">
<div className="flex items-center gap-4 flex-wrap">
<div className="flex items-center gap-2">
<label className="text-sm text-white/70"></label>
<input
type="text"
value={process}
onChange={(e) => setProcess(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleRefresh()}
className="px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white w-24 text-center"
/>
</div>
<button
onClick={handleRefresh}
className="flex items-center gap-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white transition-colors"
>
<RefreshCw className="w-4 h-4" />
</button>
<div className="flex-1" />
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40" />
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="검색..."
className="pl-9 pr-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40 w-48"
/>
</div>
</div>
</div>
{/* 테이블 */}
<div className="glass-effect rounded-xl flex-1 overflow-hidden flex flex-col">
<div className="p-4 border-b border-white/10 flex items-center gap-2">
<Users className="w-5 h-5 text-white/70" />
<h2 className="text-lg font-semibold text-white"> </h2>
<span className="text-sm text-white/50">({filteredUsers.length})</span>
</div>
<div className="flex-1 overflow-auto">
{loading ? (
<div className="flex items-center justify-center h-32">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white"></div>
</div>
) : (
<table className="w-full text-sm">
<thead className="bg-white/5 sticky top-0">
<tr>
<th className="px-3 py-2 text-left font-medium text-white/70 w-24"></th>
<th className="px-3 py-2 text-left font-medium text-white/70 w-24"></th>
<th className="px-3 py-2 text-left font-medium text-white/70 w-20"></th>
<th className="px-3 py-2 text-left font-medium text-white/70"></th>
<th className="px-3 py-2 text-left font-medium text-white/70 w-28"></th>
<th className="px-3 py-2 text-left font-medium text-white/70 w-20"></th>
<th className="px-3 py-2 text-left font-medium text-white/70 w-16"></th>
<th className="px-3 py-2 text-center font-medium text-white/70 w-12">Lv</th>
<th className="px-3 py-2 text-center font-medium text-white/70 w-16"></th>
<th className="px-3 py-2 text-center font-medium text-white/70 w-16"></th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{filteredUsers.map((user) => (
<tr
key={user.id}
onClick={() => handleRowClick(user)}
className={clsx(
'hover:bg-white/5 transition-colors cursor-pointer',
!user.useUserState && 'opacity-50'
)}
>
<td className="px-3 py-2 text-white font-mono">{user.id}</td>
<td className="px-3 py-2 text-white font-medium">{user.name}</td>
<td className="px-3 py-2 text-white/70">{user.grade}</td>
<td className="px-3 py-2">
{user.email ? (
<a
href={`mailto:${user.email}`}
className="text-blue-400 hover:text-blue-300 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{user.email}
</a>
) : (
<span className="text-white/70">-</span>
)}
</td>
<td className="px-3 py-2 text-white/70">{user.tel}</td>
<td className="px-3 py-2 text-white/70">{user.processs}</td>
<td className="px-3 py-2 text-white/70">{user.state}</td>
<td className="px-3 py-2 text-white text-center">{user.level}</td>
<td className="px-3 py-2 text-center">
{user.useUserState ? (
<Check className="w-4 h-4 text-green-400 mx-auto" />
) : (
<X className="w-4 h-4 text-red-400 mx-auto" />
)}
</td>
<td className="px-3 py-2 text-center">
{user.useJobReport ? (
<Check className="w-4 h-4 text-green-400 mx-auto" />
) : (
<X className="w-4 h-4 text-red-400 mx-auto" />
)}
</td>
</tr>
))}
{filteredUsers.length === 0 && (
<tr>
<td colSpan={10} className="px-4 py-8 text-center text-white/50">
{users.length === 0 ? '공정을 입력하고 새로고침하세요.' : '검색 결과가 없습니다.'}
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
</div>
{/* 사용자 상세 다이얼로그 */}
{selectedUser && (
<UserDetailDialog
user={selectedUser}
levelInfo={levelInfo}
onClose={() => setSelectedUser(null)}
onSave={() => loadUsers()}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,12 @@
export { Dashboard } from './Dashboard';
export { Todo } from './Todo';
export { Kuntae } from './Kuntae';
export { Jobreport } from './Jobreport';
export { PlaceholderPage } from './Placeholder';
export { Login } from './Login';
export { CommonCodePage } from './CommonCode';
export { ItemsPage } from './Items';
export { UserListPage } from './UserList';
export { MonthlyWorkPage } from './MonthlyWork';
export { MailFormPage } from './MailForm';
export { UserGroupPage } from './UserGroup';

View File

@@ -0,0 +1,467 @@
// Todo 관련 타입
export interface TodoModel {
idx: number;
gcode: string;
uid: string;
title: string;
remark: string;
flag: boolean;
expire: string | null;
seqno: number;
request: string | null;
status: string;
okdate: string | null;
wuid: string;
wdate: string;
}
// API 응답 타입
export interface ApiResponse<T = unknown> {
Success: boolean;
Message?: string;
Data?: T;
}
// Dashboard 관련 타입
export interface PurchaseCount {
NR: number;
CR: number;
Message?: string;
}
export interface HolyUser {
uid: string;
type: string;
cate: string;
sdate: string;
edate: string;
title: string;
name: string;
}
export interface HolyRequestUser {
uid: string;
cate: string;
sdate: string;
edate: string;
HolyReason: string;
name: string;
holydays: number;
holytimes: number;
remark: string;
}
export interface PurchaseItem {
pdate: string;
process: string;
pumname: string;
pumscale: string;
pumunit: string;
pumqtyreq: number;
pumprice: number;
pumamt: number;
}
// 상태 관련 타입
export type TodoStatus = '0' | '1' | '2' | '3' | '5';
export type TodoPriority = 0 | 1 | 2 | 3;
// 로그 타입
export interface LogEntry {
id: number;
timestamp: string;
message: string;
type: 'info' | 'warning' | 'error';
}
// WebView2 Native Bridge Types
declare global {
interface Window {
chrome?: {
webview?: {
hostObjects: {
machine: MachineBridgeInterface;
};
addEventListener(type: string, listener: (event: MessageEvent) => void): void;
removeEventListener(type: string, listener: (event: MessageEvent) => void): void;
postMessage(message: unknown): void;
}
}
}
}
// 근태 관련 타입
export interface KuntaeModel {
idx: number;
gcode: string;
uid: string;
uname: string;
cate: string;
sdate: string | null;
edate: string | null;
term: number;
termdr: number;
drtime: number;
crtime: number;
contents: string;
tag: string;
extcate: string;
wuid: string;
wdate: string;
}
// 업무일지 관련 타입 (기존 - 사용 안함)
export interface JobreportModel {
idx: number;
gcode: string;
uid: string;
uname: string;
pdate: string;
title: string;
contents: string;
wuid: string;
wdate: string;
}
// 업무일지 타입 (vJobReportForUser 뷰)
export interface JobReportItem {
idx: number;
pidx: number;
pdate: string;
id: string; // 사용자 ID (uid -> id)
name: string; // 사용자 이름 (username -> name)
type: string; // 업무형태
svalue: string; // 업무형태 표시값
hrs: number;
ot: number;
requestpart: string;
package: string;
userprocess: string; // 사용자 공정
status: string;
projectName: string;
description: string;
ww: string;
otpms: string; // OT PMS
process: string;
}
// 업무일지 사용자 타입
export interface JobReportUser {
id: string;
name: string;
}
// 로그인 관련 타입
export interface UserInfo {
Id: string;
Name: string;
NameE: string;
Dept: string;
Email: string;
Level: number;
Gcode: string;
}
export interface LoginStatusResponse {
Success: boolean;
IsLoggedIn: boolean;
User: UserInfo | null;
}
export interface LoginResult {
Success: boolean;
Message: string;
RedirectUrl?: string;
UserName?: string;
VersionWarning?: string;
}
export interface UserGroup {
gcode: string;
name: string;
}
export interface PreviousLoginInfo {
Success: boolean;
Data: {
LastGcode: string;
LastDept: string;
LastId: string;
};
}
// 사용자 정보 상세 타입
export interface UserInfoDetail {
Id: string;
NameK: string;
NameE: string;
Dept: string;
Grade: string;
Email: string;
Tel: string;
Hp: string;
DateIn: string;
DateO: string;
Memo: string;
Process: string;
State: string;
UseJobReport: boolean;
UseUserState: boolean;
ExceptHoly: boolean;
Level: number;
}
// 공용코드 관련 타입
export interface CommonCodeGroup {
code: string;
svalue: string;
memo: string;
}
export interface CommonCode {
idx: number;
gcode?: string;
grp: string;
code: string;
svalue: string;
ivalue: number;
fvalue: number;
svalue2?: string;
memo: string;
wuid?: string;
wdate?: string;
}
// 품목정보 관련 타입
export interface ItemInfo {
idx: number;
sid: string;
cate: string;
name: string;
model: string;
scale: string;
unit: string;
price: number;
supply: string;
manu: string;
storage: string;
disable: boolean;
memo: string;
}
// 사용자 목록 관련 타입
export interface GroupUser {
id: string;
name: string;
nameE: string;
grade: string;
email: string;
tel: string;
indate: string | null;
outdate: string | null;
hp: string;
processs: string;
state: string;
memo: string;
level: number;
useUserState: boolean;
useJobReport: boolean;
exceptHoly: boolean;
gcode: string;
dept?: string;
}
// MachineBridge 인터페이스
export interface MachineBridgeInterface {
// Todo API
Todo_GetTodos(): Promise<string>;
Todo_GetTodo(id: number): Promise<string>;
CreateTodo(title: string, remark: string, expire: string | null, seqno: number, flag: boolean, request: string | null, status: string): Promise<string>;
Todo_UpdateTodo(idx: number, title: string, remark: string, expire: string | null, seqno: number, flag: boolean, request: string | null, status: string): Promise<string>;
Todo_DeleteTodo(id: number): Promise<string>;
GetUrgentTodos(): Promise<string>;
// Dashboard API
TodayCountH(): Promise<string>;
GetHolydayRequestCount(): Promise<string>;
GetCurrentUserCount(): Promise<string>;
GetPurchaseWaitCount(): Promise<string>;
GetHolyUser(): Promise<string>;
GetHolyRequestUser(): Promise<string>;
GetPresentUserList(): Promise<string>;
GetPurchaseNRList(): Promise<string>;
GetPurchaseCRList(): Promise<string>;
// Kuntae API
Kuntae_GetList(sd: string, ed: string): Promise<string>;
Kuntae_Delete(id: number): Promise<string>;
// Jobreport API (JobReport 뷰/테이블)
Jobreport_GetList(sd: string, ed: string, uid: string, cate: string, searchKey: string): Promise<string>;
Jobreport_GetUsers(): Promise<string>;
Jobreport_GetDetail(id: number): Promise<string>;
Jobreport_Add(pdate: string, projectName: string, requestpart: string, package_: string, type: string, process: string, status: string, description: string, hrs: number, ot: number, jobgrp: string, tag: string): Promise<string>;
Jobreport_Edit(idx: number, pdate: string, projectName: string, requestpart: string, package_: string, type: string, process: string, status: string, description: string, hrs: number, ot: number, jobgrp: string, tag: string): Promise<string>;
Jobreport_Delete(id: number): Promise<string>;
Jobreport_GetPermission(targetUserId: string): Promise<string>;
Jobreport_GetJobTypes(process: string): Promise<string>;
// App Info API
GetAppVersion(): Promise<string>;
// Login API
CheckLoginStatus(): Promise<string>;
Login(gcode: string, id: string, password: string, rememberMe: boolean): Promise<string>;
Logout(): Promise<string>;
GetUserGroups(): Promise<string>;
GetPreviousLoginInfo(): Promise<string>;
// User API
GetCurrentUserInfo(): Promise<string>;
GetUserInfoById(userId: string): Promise<string>;
SaveUserInfo(jsonData: string): Promise<string>;
ChangePassword(oldPassword: string, newPassword: string): Promise<string>;
// Common Code API
Common_GetGroups(): Promise<string>;
Common_GetList(grp: string): Promise<string>;
Common_Save(idx: number, grp: string, code: string, svalue: string, ivalue: number, fvalue: number, svalue2: string, memo: string): Promise<string>;
Common_Delete(idx: number): Promise<string>;
// Items API
Items_GetCategories(): Promise<string>;
Items_GetList(category: string, searchKey: string): Promise<string>;
Items_Save(idx: number, sid: string, cate: string, name: string, model: string, scale: string, unit: string, price: number, supply: string, manu: string, storage: string, disable: boolean, memo: string): Promise<string>;
Items_Delete(idx: number): Promise<string>;
// UserList API
UserList_GetCurrentLevel(): Promise<string>;
UserList_GetList(process: string): Promise<string>;
UserList_GetUser(userId: string): Promise<string>;
UserList_SaveGroupUser(userId: string, dept: string, level: number, useUserState: boolean, useJobReport: boolean, exceptHoly: boolean): Promise<string>;
UserList_SaveUserFull(jsonData: string): Promise<string>;
UserList_DeleteGroupUser(userId: string): Promise<string>;
// Holiday API (월별근무표)
Holiday_GetList(month: string): Promise<string>;
Holiday_Save(month: string, holidaysJson: string): Promise<string>;
Holiday_Initialize(month: string): Promise<string>;
// MailForm API (메일양식)
MailForm_GetList(): Promise<string>;
MailForm_GetDetail(idx: number): Promise<string>;
MailForm_Add(cate: string, title: string, tolist: string, bcc: string, cc: string, subject: string, tail: string, body: string, selfTo: boolean, selfCC: boolean, selfBCC: boolean, exceptmail: string, exceptmailcc: string): Promise<string>;
MailForm_Edit(idx: number, cate: string, title: string, tolist: string, bcc: string, cc: string, subject: string, tail: string, body: string, selfTo: boolean, selfCC: boolean, selfBCC: boolean, exceptmail: string, exceptmailcc: string): Promise<string>;
MailForm_Delete(idx: number): Promise<string>;
// UserGroup API (그룹정보/권한설정)
UserGroup_GetList(): Promise<string>;
UserGroup_Add(dept: string, path_kj: string, permission: number, advpurchase: boolean, advkisul: boolean, managerinfo: string, devinfo: string, usemail: boolean): Promise<string>;
UserGroup_Edit(originalDept: string, dept: string, path_kj: string, permission: number, advpurchase: boolean, advkisul: boolean, managerinfo: string, devinfo: string, usemail: boolean): Promise<string>;
UserGroup_Delete(dept: string): Promise<string>;
UserGroup_GetPermissionInfo(): Promise<string>;
}
// 사용자 권한 정보 타입
export interface UserLevelInfo {
Level: number;
CurrentUserId: string;
CanEdit: boolean;
}
// 업무일지 권한 정보 타입
export interface JobReportPermission {
Success: boolean;
CurrentUserId: string;
Level: number;
CanViewOT: boolean;
}
// 업무형태 타입 (Common 테이블, grp='15')
export interface JobTypeItem {
idx: number;
code: string;
type: string; // memo - 업무형태
jobgrp: string; // svalue - 업무분류
process: string; // svalue2 - 공정
}
// 앱 버전 정보 타입
export interface AppVersionInfo {
Success: boolean;
ProductName: string;
ProductVersion: string;
DisplayVersion: string;
}
// 사용자 전체 정보 저장용 타입
export interface UserFullData {
id: string;
name: string;
nameE: string;
grade: string;
email: string;
tel: string;
hp: string;
indate: string;
outdate: string;
memo: string;
processs: string;
state: string;
level: number;
useUserState: boolean;
useJobReport: boolean;
exceptHoly: boolean;
}
// 월별근무표 항목 타입
export interface HolidayItem {
idx: number;
pdate: string;
free: boolean;
memo: string;
wuid?: string;
wdate?: string;
}
// 메일양식 항목 타입
export interface MailFormItem {
idx: number;
gcode: string;
cate: string;
title: string;
tolist: string;
bcc: string;
cc: string;
subject: string;
tail: string;
body: string;
selfTo: boolean;
selfCC: boolean;
selfBCC: boolean;
wuid: string;
wdate: string;
exceptmail: string;
exceptmailcc: string;
}
// 그룹정보 타입
export interface UserGroupItem {
dept: string;
gcode: string;
path_kj: string;
permission: number;
advpurchase: boolean;
advkisul: boolean;
managerinfo: string;
devinfo: string;
usemail: boolean;
}
// 권한 정보 타입
export interface PermissionInfo {
index: number;
name: string;
label: string;
description: string;
}

1
Project/frontend/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -0,0 +1,77 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
success: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
},
warning: {
50: '#fffbeb',
100: '#fef3c7',
200: '#fde68a',
300: '#fcd34d',
400: '#fbbf24',
500: '#f59e0b',
600: '#d97706',
700: '#b45309',
800: '#92400e',
900: '#78350f',
},
danger: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
}
},
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
}
}
},
},
plugins: [],
}

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

View File

@@ -0,0 +1,25 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, './src'),
},
},
server: {
port: 5173,
host: true,
},
build: {
outDir: 'dist',
emptyOutDir: true,
},
base: './',
});