3 Commits

Author SHA1 Message Date
b169176d57 WIP(style): UI 컴포넌트 하드코딩 제거 및 CSS 통합 (진행 중)
- 작업 상태: 진행 중 (Work In Progress)
- 주요 변경 사항:
  1. CSS 파일 통합: HWModal, SWModal, ListFactory 등에서 인라인 스타일(style 속성) 전면 제거 및 클래스 기반으로 재작성
  2. 폰트/타이포그래피 스케일업: 최소 폰트 14px 기준으로 전체 텍스트 크기 상향 및 굵기(font-weight) 상향 조정
  3. GNB(상단바) 레이아웃 개편: 2단 구조(로고 라인 / 메뉴 라인)로 변경 및 카테고리 텍스트 라벨 생략을 통한 간결화
  4. 로고 이미지 교체: image 92.png로 업데이트 및 경로 정리
  5. 디자인 가이드 분리: README에서 design_rule.md로 디자인 정책 문서 독립

* 참고: 현재 디자인 검토를 위한 중간 반영 상태이며, 피드백에 따라 추가 수정 예정임.
2026-06-12 15:57:20 +09:00
56abdddbc7 Merge remote-tracking branch 'origin/main' into ux_setting 2026-06-12 13:34:13 +09:00
fd9e88d7c6 style: 리팩토링 및 CSS 통합 작업 완료 (하드코딩 스타일 제거) 2026-06-12 13:29:59 +09:00
65 changed files with 5281 additions and 3685 deletions

View File

@@ -1,10 +0,0 @@
node_modules
dist
build
.git
.gitignore
.env
npm-debug.log
uploads
*.xlsx
*.log

6
.env Normal file
View File

@@ -0,0 +1,6 @@
DB_HOST=172.16.8.151
DB_PORT=3306
DB_USER=itam_admin
DB_PASS=itam1234
DB_NAME=itam
PORT=3000

View File

@@ -1,12 +0,0 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["npm", "run", "server"]

View File

@@ -1,12 +0,0 @@
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 8080
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]

View File

@@ -28,29 +28,8 @@
### 🎨 ITAM 시스템 디자인 가이드 (Design Guide) ### 🎨 ITAM 시스템 디자인 가이드 (Design Guide)
1. **디자인 철학 (Design Philosophy)** 디자인 일관성 및 시각적 원칙에 관한 상세 내용은 아래 문서를 참조하십시오.
* **Minimalist & Border-based**: 불필요한 박스(Card) 사용을 최소화하고, 정보의 구분은 간결한 라인(Border/Divider)을 활용하여 시각적 피로도를 낮춥니다.
* **Professional Achromatic**: 무채색(Black, White, Grey)을 기본으로 하여 정돈된 업무 환경을 제공합니다.
* **Green Accent**: 블루 대신 짙은 그린(`#1E5149`)을 포인트 컬러로 사용하여 차분한 전문성을 강조합니다.
2. **타이포그래피 (Typography)** 👉 **[디자인 가이드 바로가기 (design_rule.md)](./design_rule.md)**
* **Font Family**: `Pretendard` (전역 적용)
* **Letter Spacing**: `-0.02em` (약 -2%) 적용. 자간을 좁게 설정하여 밀도 있고 세련된 가독성을 확보합니다.
* **Weights**: 400(Regular), 500(Medium), 600(SemiBold), 700(Bold).
3. **컬러 팔레트 (Color Palette)**
* **Point Color**: `#1E5149` (Deep Green) - 강조, 활성화 상태, 주요 액션 버튼.
* **Text**: Main(`#111827` - Near Black), Muted(`#6B7280` - Grey).
* **Border/Divider**: `#E5E7EB` (Light Grey) - 정보 구분을 위한 얇은 실선.
* **Background**: `#FFFFFF` (White) / `#F9FAFB` (Off White).
4. **레이아웃 및 컴포넌트 규칙 (Layout Rules)**
* **Box-less Design**: 꼭 필요한 정보 묶음(데이터 그룹화 등)이 아니면 박스 형태의 테두리나 배경 사용을 지양합니다.
* **Line-based Division**: 섹션 간의 구분은 1px 두께의 얇은 실선(Border)을 통해 명확히 합니다.
* **Table**: 배경색이나 화려한 효과 없이 행(Row) 간의 얇은 구분선만 사용하여 데이터 본연에 집중하게 합니다.
* **Input/Button**: 입력 필드와 버튼은 최소한의 보더와 포인트 컬러만 사용하여 정갈하게 표현합니다.
* **Modal (모달 공통 규칙)**:
* **Header**: 짙은 그린(`#1E5149`) 배경에 화이트 텍스트를 사용하며, 우측 상단에 명확한 'X' 닫기 버튼을 배치합니다.
* **Interaction**: 사용자의 오입력(실수로 바깥을 클릭하여 입력 내용이 날아가는 현상)을 방지하기 위해 **모달 바깥 영역(Overlay) 클릭 시 모달이 닫히지 않도록** 설정합니다. 닫기는 오직 'ESC' 키 또는 명시적인 'X' 및 '닫기' 버튼을 통해서만 가능합니다.
* **Layout**: `detail.png` 기준의 2열 그리드 시스템을 권장하며, 하단 우측에 액션 버튼(닫기, 저장 등)을 배치합니다.

29
analyze_remote_mess.cjs Normal file
View File

@@ -0,0 +1,29 @@
const mysql = require('mysql2/promise');
require('dotenv').config({ override: true });
(async () => {
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306')
});
try {
// 1. PW 필드에 9~10자리 숫자(팀뷰어 ID 의심)가 있는 경우
const [rows1] = await pool.query("SELECT id, asset_id, net_name, net_value1, net_value2 FROM asset_remote WHERE net_value2 REGEXP '^[0-9]{9,10}$'");
console.log('--- Suspicious PW as ID ---');
console.log(JSON.stringify(rows1, null, 2));
// 2. REMOTE 타입인데 value1이 IP인 경우 (아까 확인한 거 포함)
const [rows2] = await pool.query("SELECT id, asset_id, net_name, net_value1, net_value2 FROM asset_remote WHERE net_type = 'REMOTE' AND net_value1 REGEXP '^[0-9]+\\\\.[0-9]+\\\\.[0-9]+\\\\.[0-9]+$'");
console.log('\n--- REMOTE with IP in val1 ---');
console.log(JSON.stringify(rows2, null, 2));
} catch (err) {
console.error(err);
} finally {
await pool.end();
}
})();

24
check_remote_data.cjs Normal file
View File

@@ -0,0 +1,24 @@
const mysql = require('mysql2/promise');
require('dotenv').config({ override: true });
(async () => {
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306')
});
try {
const [rows] = await pool.query(
'SELECT net_name, net_value1, net_value2 FROM asset_remote WHERE net_type = ?',
['REMOTE']
);
console.log(JSON.stringify(rows, null, 2));
} catch (err) {
console.error(err);
} finally {
await pool.end();
}
})();

24
check_remote_specific.cjs Normal file
View File

@@ -0,0 +1,24 @@
const mysql = require('mysql2/promise');
require('dotenv').config({ override: true });
(async () => {
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306')
});
try {
const [rows] = await pool.query(
'SELECT net_name, net_value1, net_value2 FROM asset_remote WHERE net_name LIKE ? OR net_name LIKE ? OR net_name LIKE ? OR net_name LIKE ?',
['%팀뷰어%', '%애니데스크%', '%TeamViewer%', '%AnyDesk%']
);
console.log(JSON.stringify(rows, null, 2));
} catch (err) {
console.error(err);
} finally {
await pool.end();
}
})();

27
check_remote_tangled.cjs Normal file
View File

@@ -0,0 +1,27 @@
const mysql = require('mysql2/promise');
require('dotenv').config({ override: true });
(async () => {
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306')
});
try {
// Find assets where REMOTE net_value1 looks like an IP and matches an existing IP row
const [rows] = await pool.query(`
SELECT r1.asset_id, r1.net_name as remote_name, r1.net_value1 as remote_val1, r2.net_value1 as ip_val1
FROM asset_remote r1
JOIN asset_remote r2 ON r1.asset_id = r2.asset_id AND r2.net_type = 'IP'
WHERE r1.net_type = 'REMOTE' AND r1.net_value1 REGEXP '^[0-9]+\\\\.[0-9]+\\\\.[0-9]+\\\\.[0-9]+$'
`);
console.log(JSON.stringify(rows, null, 2));
} catch (err) {
console.error(err);
} finally {
await pool.end();
}
})();

55
debug_save.cjs Normal file
View File

@@ -0,0 +1,55 @@
const mysql = require('mysql2/promise');
require('dotenv').config({ override: true });
(async () => {
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306')
});
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const asset = {
id: 'debug_test_' + Date.now(),
asset_code: 'SVR-240612-DEBUG',
category: '서버',
asset_type: '서버PC'
};
console.log('--- Step 1: Insert into asset_core ---');
const coreFields = ['id', 'asset_code', 'category', 'asset_type'];
const coreData = {};
coreFields.forEach(f => { if (asset[f] !== undefined) coreData[f] = asset[f]; });
const coreKeys = Object.keys(coreData);
const coreSql = `INSERT INTO asset_core (${coreKeys.join(', ')}) VALUES (${coreKeys.map(() => '?').join(', ')})`;
const [coreRes] = await connection.query(coreSql, Object.values(coreData));
console.log('Core Insert Success:', coreRes);
console.log('\n--- Step 2: Insert into asset_spec ---');
const specData = {
asset_id: asset.id,
hw_status: '운영'
};
const specKeys = Object.keys(specData);
const specSql = `INSERT INTO asset_spec (${specKeys.join(', ')}) VALUES (${specKeys.map(() => '?').join(', ')})`;
const [specRes] = await connection.query(specSql, Object.values(specData));
console.log('Spec Insert Success:', specRes);
await connection.commit();
console.log('\n✅ Transaction Committed Successfully');
} catch (err) {
await connection.rollback();
console.error('\n❌ Error Caught:', err);
} finally {
connection.release();
await pool.end();
}
})();

32
design_rule.md Normal file
View File

@@ -0,0 +1,32 @@
# 🎨 ITAM 시스템 디자인 가이드 (Design Guide)
본 문서는 ITAM(IT Asset Management System)의 시각적 일관성과 사용자 경험을 유지하기 위한 핵심 디자인 원칙을 정의합니다.
---
### 1. 디자인 철학 (Design Philosophy)
* **Minimalist & Border-based**: 불필요한 박스(Card) 사용을 최소화하고, 정보의 구분은 간결한 라인(Border/Divider)을 활용하여 시각적 피로도를 낮춥니다.
* **Professional Achromatic**: 무채색(Black, White, Grey)을 기본으로 하여 정돈된 업무 환경을 제공합니다.
* **Green Accent**: 블루 대신 짙은 그린(`#1E5149`)을 포인트 컬러로 사용하여 차분한 전문성을 강조합니다.
### 2. 타이포그래피 (Typography)
* **Font Family**: `Pretendard` (전역 적용)
* **Letter Spacing**: `-0.02em` (약 -2%) 적용. 자간을 좁게 설정하여 밀도 있고 세련된 가독성을 확보합니다.
* **Weights**: 400(Regular), 500(Medium), 600(SemiBold), 700(Bold), 800(ExtraBold).
### 3. 컬러 팔레트 (Color Palette)
* **Point Color**: `#1E5149` (Deep Green) - 강조, 활성화 상태, 주요 액션 버튼.
* **Text**: Main(`#111827` - Near Black), Muted(`#6B7280` - Grey).
* **Border/Divider**: `#E5E7EB` (Light Grey) - 정보 구분을 위한 얇은 실선.
* **Background**: `#FFFFFF` (White) / `#F9FAFB` (Off White).
### 4. 레이아웃 및 컴포넌트 규칙 (Layout Rules)
* **Box-less Design**: 꼭 필요한 정보 묶음(데이터 그룹화 등)이 아니면 박스 형태의 테두리나 배경 사용을 지양합니다.
* **Line-based Division**: 섹션 간의 구분은 1px 두께의 얇은 실선(Border)을 통해 명확히 합니다.
* **Table**: 배경색이나 화려한 효과 없이 행(Row) 간의 얇은 구분선만 사용하여 데이터 본연에 집중하게 합니다.
* **Input/Button**: 입력 필드와 버튼은 최소한의 보더와 포인트 컬러만 사용하여 정갈하게 표현합니다.
* **Standard Height**: 입력창 및 선택창은 전역 표준인 `38px`를 유지합니다.
* **Modal (모달 공통 규칙)**:
* **Header**: 짙은 그린(`#1E5149`) 배경에 화이트 텍스트를 사용하며, 우측 상단에 명확한 'X' 닫기 버튼을 배치합니다.
* **Interaction**: 사용자의 오입력(실수로 바깥을 클릭하여 입력 내용이 날아가는 현상)을 방지하기 위해 **모달 바깥 영역(Overlay) 클릭 시 모달이 닫히지 않도록** 설정합니다. 닫기는 오직 'ESC' 키 또는 명시적인 'X' 및 '닫기' 버튼을 통해서만 가능합니다.
* **Layout**: 2열 그리드 시스템을 권장하며, 하단 우측에 액션 버튼(닫기, 저장 등)을 배치합니다.

View File

@@ -1,729 +0,0 @@
# ITAM 도커라이징 실전 가이드
## 1. 문서 목적
이 문서는 Gitea에 올라가 있는 현재 저장소를 기준으로, 개발 PC에 WSL2와 Ubuntu만 설치되어 있는 상태에서 지금의 Docker 실행 구조를 재현하는 방법을 처음부터 끝까지 설명하는 실전 가이드다.
이 문서는 아래 상황을 가정한다.
1. 소스 코드는 아직 로컬에 없거나, Gitea에서 막 받아올 예정이다.
2. Windows에는 WSL2와 Ubuntu는 설치되어 있다.
3. 그 외 Docker 관련 세팅은 아직 안 되어 있을 수 있다.
4. 최종 목표는 현재 저장소 기준 `frontend + backend + external DB` 구조를 Docker로 재현하는 것이다.
이 문서의 목적은 아래 네 가지다.
1. 현재 시스템 구조와 Docker 구조를 먼저 이해하게 한다.
2. 기존 파일 중 무엇이 새로 추가되었고 무엇이 수정되었는지 정리한다.
3. 각 단계별로 정확히 어디에서 명령을 실행해야 하는지 명시한다.
4. Gitea 소스만 받은 상태에서 지금과 같은 Docker 실행 상태까지 도달하게 한다.
---
## 2. 현재 시스템 구조 개요
## 2.1 애플리케이션 원래 구조
현재 저장소의 본래 실행 구조는 다음과 같다.
1. 프런트엔드: Vite 기반 TypeScript 앱
2. 백엔드: Express 기반 Node.js API 서버
3. 데이터베이스: 외부 MySQL 서버
즉, 원래부터 MySQL이 Docker 안에 들어 있던 구조가 아니다.
프런트와 백엔드는 각각 별도 프로세스로 실행되며, 프런트는 `/api` 상대 경로로 백엔드 API를 호출한다.
---
## 2.2 현재 Docker 구조
현재 최종 Docker 구조는 아래와 같다.
1. `frontend` 컨테이너
2. `backend` 컨테이너
3. 외부 MySQL DB
즉, 지금은 내부 `db` 컨테이너가 없고, 내부 `db-bootstrap` 컨테이너도 없다.
현재 구조를 문장으로 풀면 다음과 같다.
1. 브라우저는 `http://localhost:8080`으로 `frontend` 컨테이너에 접속한다.
2. `frontend``/api` 요청을 `backend:3000`으로 프록시한다.
3. `backend``.env`에 적힌 외부 DB 정보로 외부 MySQL에 직접 접속한다.
4. 조회 결과 JSON을 프런트가 받아 화면에 렌더링한다.
간단한 흐름은 아래와 같다.
```text
Browser
-> frontend container :8080
-> Vite proxy (/api)
-> backend container :3000
-> external MySQL (.env)
```
---
## 2.3 왜 이 구조가 맞는가
현재 구조가 적절한 이유는 다음과 같다.
1. 원래 시스템도 외부 MySQL을 쓰는 구조였다.
2. 지금 목표는 운영형 단일 배포가 아니라 현재 개발형 구조를 Docker로 재현하는 것이다.
3. 프런트는 Vite dev server 기반이라 운영형 nginx 정적 배포 구조로 억지로 바꾸는 것보다, 현 구조를 유지하는 편이 안전하다.
4. 실무 표준 관점에서도 앱 컨테이너는 무상태로 유지하고, DB는 외부 인프라를 사용하는 구성이 더 일반적이다.
---
## 3. 이번 도커라이징에서 추가되거나 수정된 파일 정리
아래 파일들은 이번 Docker 재현 구조를 위해 새로 추가되었거나 수정된 핵심 파일이다.
## 3.1 새로 추가된 파일
1. `Dockerfile.frontend`
2. `Dockerfile.backend`
3. `.dockerignore`
4. `docker-compose.yaml`
5. `start_docker_wsl.ps1`
6. `stop_docker_wsl.ps1`
7. `start_docker_wsl.bat`
8. `stop_docker_wsl.bat`
9. `docker/mysql/init/README.md`
10. `docker_task_plan.md`
11. `doc_readme2.md`
---
## 3.2 기존 파일 중 수정된 핵심 파일
1. `server.js`
2. `vite.config.ts`
3. `doc_readme.md`
---
## 3.3 각 파일의 역할
### `Dockerfile.frontend`
역할:
1. 프런트 Vite 개발 서버 이미지를 만든다.
2. 컨테이너 내부에서 `npm run dev -- --host 0.0.0.0`를 실행한다.
### `Dockerfile.backend`
역할:
1. 백엔드 Express 서버 이미지를 만든다.
2. 컨테이너 내부에서 `npm run server`를 실행한다.
### `.dockerignore`
역할:
1. `node_modules`, `build`, `.git`, `.env`, `uploads` 같은 불필요한 파일을 Docker build context에서 제외한다.
### `docker-compose.yaml`
역할:
1. `frontend`, `backend` 두 컨테이너를 동시에 띄운다.
2. `backend``.env`의 외부 DB를 사용한다.
3. `frontend``backend:3000`으로 프록시한다.
### `start_docker_wsl.ps1`
역할:
1. Windows 경로를 WSL 경로로 안전하게 바꾼다.
2. WSL 내부 Docker를 사용해 `docker compose up --build -d`를 실행한다.
3. 한글 경로와 공백 경로에서도 안정적으로 실행되게 한다.
### `stop_docker_wsl.ps1`
역할:
1. 같은 방식으로 WSL 내부에서 `docker compose down`을 실행한다.
### `start_docker_wsl.bat`, `stop_docker_wsl.bat`
역할:
1. PowerShell 스크립트를 쉽게 실행하는 래퍼 역할을 한다.
### `server.js`
중요 수정 사항:
1. `dotenv.config({ override: true })`가 아니라 `dotenv.config()`를 사용한다.
이유:
1. Compose나 실행 환경이 주는 환경변수를 `.env`가 덮어써 버리면 안 된다.
2. 외부 DB 정보와 포트 설정 등 실행 환경 우선 구조를 유지해야 한다.
### `vite.config.ts`
중요 수정 사항:
1. 프록시 타깃을 고정 `localhost:3000`이 아니라 환경변수 기반으로 받도록 바꿨다.
현재 구조:
```ts
const proxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3000';
```
이유:
1. 로컬에서 직접 프런트를 띄울 때는 `localhost:3000`이 맞다.
2. Docker 안에서는 `frontend` 컨테이너에서 보는 `localhost`가 백엔드가 아니므로 `backend:3000`을 써야 한다.
---
## 4. 현재 `docker-compose.yaml` 기준 실제 동작 구조
현재 `docker-compose.yaml`은 아래 구조다.
### `backend`
1. `Dockerfile.backend`로 이미지를 빌드한다.
2. `.env`를 읽는다.
3. DB 관련 변수는 `${DB_HOST}`, `${DB_PORT}`, `${DB_USER}`, `${DB_PASS}`, `${DB_NAME}`를 그대로 사용한다.
4. 포트 `3000:3000`으로 노출한다.
5. `uploads`, `map_config.json`을 마운트한다.
### `frontend`
1. `Dockerfile.frontend`로 이미지를 빌드한다.
2. `VITE_DEV_PROXY_TARGET: http://backend:3000` 환경변수를 사용한다.
3. 포트 `8080:8080`으로 노출한다.
4. 브라우저의 `/api` 요청을 `backend`로 프록시한다.
즉, 현재 Compose는 DB를 띄우지 않고 앱 두 개만 띄운다.
---
## 5. 사전 준비 사항
이 섹션은 Gitea에서 코드를 받기 전 또는 받은 직후에 확인해야 한다.
## 5.1 가정하는 기본 상태
이미 설치되어 있다고 가정하는 것:
1. Windows
2. WSL2
3. Ubuntu 배포판
아직 없을 수 있는 것:
1. Docker Desktop 또는 WSL 내부 Docker 사용 환경
2. Git 클라이언트
3. 프로젝트 `.env`
---
## 5.2 권장 Docker 실행 방식
현재 저장소 구조상 가장 권장하는 방식은 다음이다.
1. Windows에 Docker Desktop 설치
2. Docker Desktop에서 WSL2 통합 활성화
3. Ubuntu WSL 내부에서 `docker` 명령을 사용할 수 있게 한다.
이유:
1. 현재 `start_docker_wsl.ps1`가 WSL 내부의 `docker`를 호출하는 구조다.
2. 실제 검증도 WSL 내부 Docker 기준으로 이루어졌다.
---
## 5.3 외부 DB 정보 준비
현재 구조는 외부 MySQL을 사용하므로 `.env` 파일이 반드시 필요하다.
최소한 아래 값이 필요하다.
```env
DB_HOST=<외부 MySQL 호스트>
DB_PORT=3306
DB_USER=<외부 MySQL 계정>
DB_PASS=<외부 MySQL 비밀번호>
DB_NAME=itam
```
필요 시 추가 환경변수는 현재 백엔드 코드 기준으로 함께 넣을 수 있다.
---
## 6. Gitea에서 소스 받기
## 6.1 작업 실행 위치
이 단계는 **Windows PowerShell** 또는 **Windows 터미널의 PowerShell**에서 수행한다.
실행 위치 이유:
1. 이후 `start_docker_wsl.ps1`도 Windows PowerShell에서 실행하는 것이 가장 자연스럽다.
2. 로컬 작업 폴더를 Windows 경로 기준으로 준비할 수 있다.
---
## 6.2 소스 클론
예시:
```powershell
git clone <Gitea 저장소 URL>
cd <클론된 저장소 경로>
```
현재 프로젝트처럼 한글 경로를 사용할 수도 있지만, 가능하면 너무 복잡한 경로는 피하는 것이 좋다.
현재 실제 프로젝트 경로 예시는 아래였다.
```text
c:\Users\user\Desktop\안건 파일\itam
```
이 경로도 현재 스크립트로는 동작 가능하다.
---
## 7. Docker 환경 준비
## 7.1 작업 실행 위치
이 단계는 **Windows PowerShell**과 **WSL Ubuntu 터미널**을 둘 다 사용한다.
1. 설치 확인은 Windows PowerShell에서 시작
2. 실제 Docker 동작 확인은 WSL Ubuntu에서 수행
---
## 7.2 Docker Desktop 설치 여부 확인
**실행 위치: Windows PowerShell**
```powershell
docker version
```
만약 여기서 바로 안 잡혀도 현재 프로젝트는 WSL 내부 Docker를 쓰므로, 다음 단계로 넘어가 WSL 내부 확인을 한다.
---
## 7.3 WSL 내부 Docker 확인
**실행 위치: Windows PowerShell**
```powershell
wsl -l -v
wsl sh -lc "docker --version"
```
정상 기대 결과:
1. Ubuntu가 Running 상태
2. `docker --version`이 정상 출력
만약 `docker --version`이 실패하면, Docker Desktop 설치 및 WSL 통합을 먼저 완료해야 한다.
---
## 8. `.env` 파일 준비
## 8.1 작업 실행 위치
이 단계는 **Windows PowerShell**, **VS Code**, 또는 아무 텍스트 편집기**에서 수행한다.
즉, 프로젝트 루트에 `.env` 파일을 만드는 작업이다.
---
## 8.2 `.env` 작성
프로젝트 루트에 `.env`를 만든다.
예시:
```env
DB_HOST=your-external-db-host
DB_PORT=3306
DB_USER=your-db-user
DB_PASS=your-db-password
DB_NAME=itam
```
주의:
1. 현재 Compose는 내부 DB를 만들지 않는다.
2. 따라서 이 값이 곧 실제 운영/개발 외부 DB 연결 정보다.
3. 이 정보가 틀리면 `backend`는 기동해도 API에서 DB 오류가 난다.
---
## 9. 현재 Docker 파일이 어떻게 동작하는지 이해하기
## 9.1 `Dockerfile.frontend`
**확인 위치: 프로젝트 루트 / VS Code**
현재 내용 핵심:
```dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 8080
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
```
의미:
1. Node 20 Alpine 기반
2. 의존성 설치 후 전체 소스 복사
3. Vite 개발 서버 실행
---
## 9.2 `Dockerfile.backend`
**확인 위치: 프로젝트 루트 / VS Code**
현재 내용 핵심:
```dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["npm", "run", "server"]
```
의미:
1. Node 20 Alpine 기반
2. Express 서버 실행
---
## 9.3 `vite.config.ts`
**확인 위치: 프로젝트 루트 / VS Code**
현재 핵심:
```ts
const proxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3000';
```
그리고 `/api`, `/uploads`가 모두 `proxyTarget`으로 프록시된다.
의미:
1. 로컬 실행 시 기본값은 `localhost:3000`
2. Docker 실행 시 Compose가 `http://backend:3000`을 주입
이 수정이 있어야 Docker 안에서도 화면에 데이터가 표시된다.
---
## 10. Docker Compose 기동
## 10.1 작업 실행 위치
이 단계는 반드시 **Windows PowerShell**에서 수행하는 것을 권장한다.
이유:
1. `start_docker_wsl.ps1`가 Windows 경로를 받아 WSL 경로로 바꾸는 구조다.
2. 한글/공백 경로에서 가장 안전하다.
---
## 10.2 권장 기동 방법
**실행 위치: 프로젝트 루트의 Windows PowerShell**
```powershell
.\start_docker_wsl.ps1
```
또는
```powershell
.\start_docker_wsl.bat
```
이 스크립트는 내부적으로 아래를 수행한다.
1. PowerShell 출력 인코딩을 UTF-8로 설정
2. 현재 Windows 경로를 WSL 경로로 변환
3. WSL 동작 확인
4. WSL 내부 Docker 동작 확인
5. `docker compose up --build -d` 수행
---
## 10.3 직접 기동이 필요할 때
**실행 위치: WSL Ubuntu 터미널**
직접 실행 예시는 아래와 같다.
```bash
cd /mnt/c/Users/user/Desktop/안건\ 파일/itam
docker compose up --build -d
```
하지만 현재 프로젝트는 한글 경로 이슈가 있었기 때문에, 특별한 이유가 없으면 `start_docker_wsl.ps1`를 우선 사용한다.
---
## 11. 컨테이너 기동 후 검증
## 11.1 컨테이너 상태 확인
**실행 위치: Windows PowerShell**
```powershell
wsl sh -lc "docker ps -a --format 'table {{.Names}}\t{{.Status}}' | grep itam"
```
정상 기대 상태:
1. `itam-backend` -> `Up`
2. `itam-frontend` -> `Up`
현재는 `itam-db`, `itam-db-bootstrap`가 없어야 정상이다.
---
## 11.2 백엔드 API 확인
**실행 위치: Windows PowerShell**
```powershell
Invoke-WebRequest -Uri http://localhost:3000/api/assets/master -UseBasicParsing | Select-Object -ExpandProperty StatusCode
```
정상 기대값:
1. `200`
이 검사는 `backend`가 외부 DB에 정상 연결됐는지 보는 가장 직접적인 검사다.
---
## 11.3 프런트 경유 API 확인
**실행 위치: Windows PowerShell**
```powershell
Invoke-WebRequest -Uri http://localhost:8080/api/assets/master -UseBasicParsing | Select-Object -ExpandProperty StatusCode
```
정상 기대값:
1. `200`
이 검사는 프런트 프록시가 정상인지 확인한다.
예전에 화면에 데이터가 안 보였던 것은 외부 DB 자체가 아니라, 이 프록시 경로가 잘못돼 있었기 때문이다.
---
## 11.4 브라우저 화면 확인
**실행 위치: 브라우저**
```text
http://localhost:8080
```
확인 포인트:
1. 화면이 열리는지
2. 목록/대시보드/테이블 데이터가 비어 있지 않은지
3. 모달 진입 시 데이터가 정상적으로 보이는지
---
## 12. 지금 데이터가 표시되는 원리
현재는 내부 DB로 데이터를 옮겨 담지 않는다.
현재 실제 동작 원리는 다음과 같다.
1. 브라우저가 `frontend`에 접속한다.
2. 프런트가 `/api/...`로 요청한다.
3. Vite 프록시가 `backend:3000`으로 요청을 넘긴다.
4. `backend``.env`의 외부 MySQL에 직접 접속한다.
5. 조회 결과 JSON을 프런트가 받아 화면에 렌더링한다.
즉, 현재는 아래 구조다.
```text
Browser -> frontend -> backend -> external MySQL
```
예전 외부 DB 구조에서 화면에 데이터가 안 보였던 이유는 외부 DB 때문이 아니라, 프런트 컨테이너가 `localhost:3000`을 잘못 바라보고 있었기 때문이다.
지금은 `VITE_DEV_PROXY_TARGET: http://backend:3000`으로 수정되어 있기 때문에 정상 표시된다.
---
## 13. 자주 헷갈리는 포인트
## 13.1 현재는 내부 DB 컨테이너가 없다
현재 `docker-compose.yaml`에는 아래가 없다.
1. `db` 서비스
2. `db-bootstrap` 서비스
3. `itam_mysql_data` 볼륨
즉, DB는 Docker 스택 밖에 있다.
---
## 13.2 현재는 `.env`가 곧 실제 DB 연결 정보다
현재 `backend`는 아래처럼 Compose에서 그대로 받는다.
1. `DB_HOST: ${DB_HOST}`
2. `DB_PORT: ${DB_PORT}`
3. `DB_USER: ${DB_USER}`
4. `DB_PASS: ${DB_PASS}`
5. `DB_NAME: ${DB_NAME}`
즉, `.env`를 틀리게 적으면 화면도 데이터가 안 뜬다.
---
## 13.3 `server.js`는 여전히 중요하게 수정된 상태다
현재 `server.js``dotenv.config()`를 사용한다.
이 구조는 이후 Compose나 실행 환경에서 변수를 주입할 때, 애플리케이션이 그 값을 받아들일 수 있게 하기 위해 유지해야 한다.
---
## 14. 스택 중지 방법
## 14.1 작업 실행 위치
**Windows PowerShell / 프로젝트 루트**
---
## 14.2 권장 종료 명령
```powershell
.\stop_docker_wsl.ps1
```
또는
```powershell
.\stop_docker_wsl.bat
```
이 스크립트는 내부적으로 WSL 경로 변환 후 `docker compose down`을 수행한다.
---
## 15. 장애 발생 시 점검 순서
## 15.1 `frontend` 화면은 뜨는데 데이터가 없을 때
**실행 위치: Windows PowerShell**
먼저 아래 두 API를 분리해서 본다.
```powershell
Invoke-WebRequest -Uri http://localhost:3000/api/assets/master -UseBasicParsing | Select-Object -ExpandProperty StatusCode
Invoke-WebRequest -Uri http://localhost:8080/api/assets/master -UseBasicParsing | Select-Object -ExpandProperty StatusCode
```
판단 기준:
1. `3000`은 200이고 `8080`만 실패 -> 프런트 프록시 문제
2. 둘 다 실패 -> 백엔드 또는 외부 DB 연결 문제
---
## 15.2 백엔드가 외부 DB에 연결되지 않을 때
**실행 위치: Windows PowerShell**
```powershell
wsl sh -lc "docker logs --tail=200 itam-backend"
```
점검 항목:
1. `.env`의 DB 정보가 정확한지
2. 외부 DB 서버 접근이 가능한지
3. 계정/비밀번호가 맞는지
4. 방화벽 또는 네트워크 이슈가 없는지
---
## 15.3 프런트 프록시가 의심될 때
**확인 위치: `vite.config.ts`, `docker-compose.yaml`**
다음 두 설정이 유지되는지 확인한다.
`vite.config.ts`
```ts
const proxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3000';
```
`docker-compose.yaml`
```yaml
VITE_DEV_PROXY_TARGET: http://backend:3000
```
이 둘 중 하나라도 바뀌면 Docker 안에서 화면 데이터가 다시 안 보일 수 있다.
---
## 16. 현재 기준 재현 절차 요약
가장 짧게 정리하면 아래 순서다.
1. Gitea에서 소스를 클론한다.
2. Windows PowerShell에서 프로젝트 루트로 이동한다.
3. `.env`에 외부 MySQL 정보를 작성한다.
4. Docker Desktop + WSL 통합 또는 WSL 내부 Docker 사용 가능 상태를 만든다.
5. `start_docker_wsl.ps1`를 실행한다.
6. `http://localhost:3000/api/assets/master`가 200인지 확인한다.
7. `http://localhost:8080/api/assets/master`가 200인지 확인한다.
8. 브라우저에서 `http://localhost:8080`을 열어 실제 데이터 표시를 확인한다.
---
## 17. 현재 최종 결론
현재 저장소의 도커라이징 구조는 실무 표준에 맞는 `무상태 앱 컨테이너 + 외부 DB` 구조다.
현재 핵심은 아래 세 가지다.
1. `backend`는 외부 MySQL에 직접 연결한다.
2. `frontend``backend:3000`으로 API 프록시한다.
3. WSL 경로 변환 스크립트를 통해 Windows 한글 경로에서도 안정적으로 실행한다.
즉, 이 문서대로 진행하면 Gitea 소스만 받은 상태에서 지금과 같은 Docker 실행 구조를 재현할 수 있다.

View File

@@ -1,730 +0,0 @@
# ITAM 도커라이징 최종 재현 가이드
## 1. 문서 목적
이 문서는 현재 Git 저장소에 올라간 파일만 가지고, 지금과 동일한 수준으로 ITAM 시스템을 도커라이징하고 실행하는 절차를 처음부터 끝까지 정리한 최종 가이드다.
이 문서만 읽어도 아래 목표를 달성할 수 있게 작성한다.
1. 현재 저장소 구조를 이해한다.
2. 왜 이렇게 도커라이징했는지 판단 근거를 안다.
3. WSL2 기반으로 실제 스택을 기동한다.
4. 외부 MySQL에서 내부 MySQL 컨테이너로 초기 데이터를 bootstrap 한다.
5. 프런트 8080과 백엔드 3000이 모두 정상 동작하는지 검증한다.
6. 재초기화, 재기동, 장애 확인까지 수행한다.
이 문서는 최종 성공 구조 기준이다. 실패 기록은 `doc_readme.md`를 본다.
---
## 2. 최종 목표 구조
현재 최종 구조는 아래 4개 서비스/역할로 나뉜다.
1. `frontend`: Vite 개발 서버 컨테이너, 포트 8080
2. `backend`: Express API 서버 컨테이너, 포트 3000
3. `db`: MySQL 8 컨테이너, 포트 3306
4. `db-bootstrap`: 외부 MySQL -> 내부 MySQL로 1회성 복제 수행 후 종료되는 도우미 컨테이너
논리 흐름은 다음과 같다.
```text
브라우저 -> frontend:8080 -> Vite proxy -> backend:3000 -> db:3306
\
-> /uploads -> backend 정적 경로
초기 1회 기동 시
외부 MySQL(.env) -> db-bootstrap -> 내부 MySQL(db)
```
---
## 3. 왜 이 구조를 선택했는가
이 저장소는 처음부터 운영형 정적 배포 앱이 아니었다. 실제 구조는 다음과 같았다.
1. 프런트는 Vite 개발 서버가 따로 돈다.
2. 백엔드는 Express API가 따로 돈다.
3. 프런트는 상대 경로 `/api`를 호출한다.
4. 백엔드는 프런트의 `dist`를 서빙하지 않는다.
따라서 내일 바로 시연 가능한 수준까지 빠르게 안정화하려면 아래 전략이 가장 맞다.
1. 프런트를 Vite dev server 그대로 컨테이너화한다.
2. 백엔드를 별도 컨테이너로 유지한다.
3. DB는 MySQL 8 컨테이너로 묶되, 초기 데이터는 외부 DB에서 복제한다.
4. 프런트 프록시는 컨테이너 네트워크 서비스명 `backend`로 붙게 한다.
즉, 현재 구조는 "개발형 구조를 Docker로 재현한 시연/개발용 Compose"다.
---
## 4. 저장소 내 최종 관련 파일 목록
현재 도커라이징과 직접 관련된 핵심 파일은 아래와 같다.
1. `.dockerignore`
2. `Dockerfile.frontend`
3. `Dockerfile.backend`
4. `docker-compose.yaml`
5. `start_docker_wsl.ps1`
6. `stop_docker_wsl.ps1`
7. `start_docker_wsl.bat`
8. `stop_docker_wsl.bat`
9. `docker/mysql/init/README.md`
10. `server.js`
11. `vite.config.ts`
각 파일 역할은 다음과 같다.
### 4.1 `.dockerignore`
Docker build context에서 제외할 파일을 정의한다.
주요 제외 대상은 다음과 같다.
1. `node_modules`
2. `dist`
3. `build`
4. `.git`
5. `.env`
6. `uploads`
7. `*.xlsx`
8. `*.log`
### 4.2 `Dockerfile.frontend`
프런트 컨테이너 이미지 정의다.
```dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 8080
CMD ["npm", "run", "dev", "--", "--host", "0.0.0.0"]
```
이 이미지는 Vite dev server를 컨테이너에서 띄우기 위한 것이다.
### 4.3 `Dockerfile.backend`
백엔드 컨테이너 이미지 정의다.
```dockerfile
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
EXPOSE 3000
CMD ["npm", "run", "server"]
```
### 4.4 `docker-compose.yaml`
전체 스택의 핵심 파일이다.
현재 최종 구성은 다음 논리를 가진다.
1. `db`는 MySQL 8 내부 DB다.
2. `db-bootstrap`은 외부 DB 데이터를 내부 DB로 1회 복제한다.
3. `backend`는 내부 `db`에 붙는다.
4. `frontend``backend` 서비스명으로 프록시한다.
### 4.5 `start_docker_wsl.ps1`
Windows에서 WSL 경유로 Docker Compose를 안전하게 기동하는 진입점이다.
핵심은 다음 두 가지다.
1. 프로젝트 Windows 경로를 `wslpath`로 WSL 경로로 바꾼다.
2. 그 경로로 이동한 뒤 `docker compose up --build -d`를 수행한다.
### 4.6 `stop_docker_wsl.ps1`
같은 방식으로 WSL 내부에서 `docker compose down`을 수행해 스택을 안전하게 내린다.
### 4.7 `start_docker_wsl.bat`, `stop_docker_wsl.bat`
더블클릭 또는 간단 실행용 래퍼다. 내부적으로 PowerShell 스크립트를 호출한다.
### 4.8 `server.js`
중요 포인트는 다음 두 가지다.
1. `dotenv.config();`를 사용한다.
2. `dotenv.config({ override: true })`를 사용하지 않는다.
이 차이로 Compose 환경변수 `DB_HOST=db``.env`보다 우선하도록 보장한다.
### 4.9 `vite.config.ts`
현재 프록시는 환경변수 기반으로 동작한다.
```ts
const proxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3000';
```
로컬 PC에서 직접 Vite를 띄우면 기본값 `http://localhost:3000`을 쓴다.
컨테이너에서는 Compose가 `http://backend:3000`을 주입한다.
---
## 5. 현재 최종 `docker-compose.yaml` 구조 설명
아래는 실제 동작 관점에서 읽어야 할 핵심 내용이다.
### 5.1 `db` 서비스
역할:
1. 내부 MySQL 데이터 저장소
2. 앱이 최종적으로 붙는 DB
핵심 설정:
1. 이미지: `mysql:8.0`
2. DB 이름: `itam`
3. 앱 계정: `itam_admin`
4. 데이터 볼륨: `itam_mysql_data`
5. healthcheck 사용
healthcheck는 `mysqladmin ping`으로 동작하며, `backend``db-bootstrap`은 이 상태를 기다린다.
### 5.2 `db-bootstrap` 서비스
역할:
1. 외부 원본 DB에서 내부 `db`로 초기 데이터 복제
2. 1회성 작업 후 종료
핵심 포인트:
1. `.env`를 읽어 외부 DB 접속 정보를 가져온다.
2. 내부 `db``asset_core` 테이블이 이미 존재하면 아무 것도 하지 않고 종료한다.
3. 그렇지 않으면 `mysqldump | mysql` 파이프라인으로 복제한다.
4. `restart: "no"` 이므로 정상 종료 후 반복 실행하지 않는다.
또한 source DB와 target DB 변수는 분리돼 있다.
1. source: `SOURCE_DB_*`
2. target: `TARGET_DB_*`
이 구조로 외부 원본 DB 자격증명과 내부 컨테이너 DB 자격증명이 섞이지 않는다.
### 5.3 `backend` 서비스
역할:
1. Express API 제공
2. 내부 `db`에 연결
3. `/uploads` 정적 제공
핵심 포인트:
1. `env_file: .env`를 유지하지만,
2. Compose `environment`에서 `DB_HOST=db`, `DB_PORT=3306`, `DB_USER=itam_admin`, `DB_PASS=itam1234`, `DB_NAME=itam`를 다시 지정한다.
3. `depends_on``db` healthy와 `db-bootstrap` 성공 종료를 모두 기다린다.
즉, 백엔드는 DB bootstrap이 끝난 뒤 시작한다.
### 5.4 `frontend` 서비스
역할:
1. Vite dev server 제공
2. 브라우저 요청 `/api`, `/uploads``backend`로 프록시
핵심 포인트:
1. `VITE_DEV_PROXY_TARGET: http://backend:3000`
2. `CHOKIDAR_USEPOLLING: "true"`
3. `npm run dev -- --host 0.0.0.0`
중요한 이유는 컨테이너 안의 `localhost`가 호스트의 `localhost`가 아니기 때문이다.
---
## 6. 사전 준비 조건
이 저장소를 지금처럼 기동하려면 다음 전제가 필요하다.
### 6.1 운영체제와 런타임
1. Windows
2. WSL2 Ubuntu 설치 및 실행 중
3. Docker CLI가 WSL 내부에서 동작 가능
권장 확인 명령:
```powershell
wsl -l -v
wsl sh -lc "docker --version"
```
### 6.2 `.env` 파일
현재 최종 구조는 "첫 기동 시 외부 DB에서 내부 DB로 bootstrap" 하는 방식이므로 `.env`가 반드시 필요하다.
최소한 다음 값은 외부 원본 DB를 가리켜야 한다.
```env
DB_HOST=<external-mysql-host>
DB_PORT=3306
DB_USER=<external-db-user>
DB_PASS=<external-db-password>
DB_NAME=itam
```
주의:
1. `.env``db-bootstrap`이 외부 원본 DB에 접속할 때 사용한다.
2. `backend`는 최종적으로 내부 `db` 컨테이너를 쓰므로, 런타임에서는 Compose `environment`가 우선한다.
### 6.3 한글 경로 주의
현재 프로젝트 경로는 한글과 공백을 포함한다.
```text
c:\Users\user\Desktop\안건 파일\itam
```
이 때문에 Docker 관련 명령은 수동으로 경로를 조립하지 말고, `start_docker_wsl.ps1` / `stop_docker_wsl.ps1`을 우선 사용해야 한다.
---
## 7. 첫 기동 절차
이 절차는 "Git에서 소스를 받은 뒤 처음 올리는 경우" 기준이다.
### 7.1 저장소 준비
1. 저장소를 받는다.
2. `.env`가 올바른 외부 원본 DB를 가리키는지 확인한다.
3. WSL이 켜져 있는지 확인한다.
### 7.2 권장 실행 방법
Windows PowerShell에서 프로젝트 루트로 이동한 뒤 아래 중 하나를 사용한다.
방법 A:
```powershell
.\start_docker_wsl.ps1
```
방법 B:
```powershell
.\start_docker_wsl.bat
```
### 7.3 내부 실행 순서
스크립트는 내부적으로 다음 순서로 동작한다.
1. 현재 Windows 경로를 WSL 경로로 변환한다.
2. WSL 동작 여부를 확인한다.
3. WSL 내부 Docker 사용 가능 여부를 확인한다.
4. `docker compose up --build -d`를 수행한다.
### 7.4 기대되는 컨테이너 순서
정상이라면 다음 순서로 올라온다.
1. `itam-db`
2. `itam-db-bootstrap`
3. `itam-backend`
4. `itam-frontend`
`itam-db-bootstrap`은 정상이라면 최종 상태가 `Exited (0)`이어야 한다.
---
## 8. 첫 기동 후 검증 절차
기동 후에는 반드시 아래 검증을 수행한다.
### 8.1 컨테이너 상태 확인
```powershell
wsl sh -lc "docker ps -a --format 'table {{.Names}}\t{{.Status}}' | grep itam"
```
정상 기대 상태:
1. `itam-db` -> `Up ... (healthy)`
2. `itam-db-bootstrap` -> `Exited (0)`
3. `itam-backend` -> `Up`
4. `itam-frontend` -> `Up`
### 8.2 백엔드 API 직접 확인
```powershell
Invoke-WebRequest -Uri http://localhost:3000/api/assets/master -UseBasicParsing | Select-Object -ExpandProperty StatusCode
```
정상 기대값:
1. `200`
### 8.3 프런트 경유 API 확인
```powershell
Invoke-WebRequest -Uri http://localhost:8080/api/assets/master -UseBasicParsing | Select-Object -ExpandProperty StatusCode
```
정상 기대값:
1. `200`
### 8.4 데이터가 실제로 들어왔는지 확인
```powershell
wsl sh -lc "docker exec itam-db mysql -uitam_admin -pitam1234 -D itam -e 'SHOW TABLES' | head -n 20"
```
정상이라면 아래와 같은 테이블들이 보여야 한다.
1. `asset_core`
2. `asset_remote`
3. `asset_spec`
4. `asset_location`
5. `asset_history`
6. `asset_software_perpetual`
7. `asset_software_subscription`
8. `hardware_components_master`
9. `job_spec_standards`
### 8.5 브라우저 화면 확인
브라우저에서 아래 주소를 연다.
```text
http://localhost:8080
```
목록/대시보드 데이터가 보이면 화면까지 정상 연결된 것이다.
---
## 9. 재기동 절차
코드만 수정됐고 DB는 유지하고 싶다면 다음처럼 하면 된다.
### 9.1 스택 종료
```powershell
.\stop_docker_wsl.ps1
```
또는
```powershell
.\stop_docker_wsl.bat
```
### 9.2 스택 재기동
```powershell
.\start_docker_wsl.ps1
```
이 경우 `itam_mysql_data` 볼륨이 유지되므로, `db-bootstrap`은 내부 DB에 `asset_core`가 이미 있음을 감지하고 빠르게 종료한다.
---
## 10. DB를 완전히 다시 초기화하는 절차
외부 원본 DB에서 다시 처음부터 내부 DB를 복제하고 싶다면, MySQL 볼륨을 제거해야 한다.
### 10.1 스택 중지
```powershell
.\stop_docker_wsl.ps1
```
### 10.2 MySQL 데이터 볼륨 삭제
```powershell
wsl sh -lc "docker volume rm -f itam_itam_mysql_data"
```
### 10.3 다시 시작
```powershell
.\start_docker_wsl.ps1
```
이때 `db-bootstrap`이 외부 DB에서 내부 DB로 전체를 다시 복제한다.
---
## 11. 현재 구조에서 꼭 알아야 할 설계 포인트
### 11.1 `server.js`의 `dotenv.config()` 변경 이유
백엔드가 내부 DB로 붙게 하려면 Compose가 준 환경변수가 `.env`보다 우선해야 한다.
만약 아래처럼 `override: true`를 쓰면 안 된다.
```js
dotenv.config({ override: true });
```
이렇게 되면 내부 `db`가 아니라 `.env`의 외부 DB로 다시 붙을 수 있다.
현재는 아래가 맞다.
```js
dotenv.config();
```
### 11.2 왜 `docker-entrypoint-initdb.d` 기반 dump 파일을 안 쓰는가
처음에는 이 방식을 시도했지만, 실제 데이터의 긴 문자열/깨진 텍스트 때문에 import가 line 97에서 중단됐다.
그래서 현재는 더 안정적인 아래 방식을 쓴다.
1. 외부 DB에서 `mysqldump`
2. 파이프로 내부 `db`에 즉시 `mysql` import
즉, 파일 중간 생성물을 신뢰하지 않는 구조다.
### 11.3 왜 프런트 프록시 타깃을 환경변수화했는가
로컬 직접 실행과 컨테이너 실행의 네트워크 기준이 다르기 때문이다.
1. 로컬 직접 실행: `localhost:3000`이 맞다.
2. 컨테이너 내부 실행: `backend:3000`이 맞다.
그래서 `vite.config.ts`는 둘 다 수용할 수 있게 작성됐다.
---
## 12. 문제 발생 시 진단 순서
이 프로젝트에서는 문제를 아래 순서로 자르면 가장 빠르다.
### 12.1 브라우저 화면에 데이터가 없을 때
먼저 다음 둘을 분리해서 본다.
1. `http://localhost:3000/api/assets/master`
2. `http://localhost:8080/api/assets/master`
판단 기준:
1. `3000`은 200이고 `8080`만 실패면 프런트 프록시 문제다.
2. 둘 다 실패면 백엔드 또는 DB 문제다.
### 12.2 DB bootstrap이 성공했는지 확인할 때
```powershell
wsl sh -lc "docker ps -a --format 'table {{.Names}}\t{{.Status}}' | grep itam"
```
여기서 `itam-db-bootstrap``Exited (0)`인지 본다.
### 12.3 내부 DB에 실제 데이터가 있는지 확인할 때
```powershell
wsl sh -lc "docker exec itam-db mysql -uitam_admin -pitam1234 -D itam -e 'SHOW TABLES'"
```
### 12.4 백엔드 로그 확인
```powershell
wsl sh -lc "docker logs --tail=200 itam-backend"
```
### 12.5 DB 로그 확인
```powershell
wsl sh -lc "docker logs --tail=200 itam-db"
```
### 12.6 프런트 로그 확인
```powershell
wsl sh -lc "docker logs --tail=200 itam-frontend"
```
---
## 13. 자주 나올 수 있는 장애와 해석
### 13.1 `docker` 명령이 PowerShell에서 안 보임
의미:
1. Windows 셸이 아니라 WSL에서 Docker를 쓰는 환경이다.
대응:
1. `start_docker_wsl.ps1` 사용
### 13.2 `asset_core` 테이블 없음
의미:
1. 내부 DB 초기화가 안 됐거나 bootstrap이 안 끝났다.
대응:
1. `db-bootstrap` 상태 확인
2. `.env` 외부 DB 접속 정보 확인
3. 필요하면 볼륨 삭제 후 재초기화
### 13.3 `3000` API는 되는데 화면은 비어 있음
의미:
1. DB는 정상이고 프런트 프록시 또는 화면 렌더링 문제다.
대응:
1. `8080/api/assets/master` 상태 먼저 확인
### 13.4 `db-bootstrap`가 실패 종료함
의미 후보:
1. `.env` 외부 DB 접속 정보 오류
2. 외부 DB 네트워크 접근 불가
3. 외부 계정 권한 문제
대응:
1. `docker logs itam-db-bootstrap` 확인
---
## 14. 현재 최종 검증 완료 상태
이 저장소는 아래 상태까지 검증이 완료됐다.
1. WSL2 Ubuntu에서 Docker 실행 가능
2. `start_docker_wsl.ps1`로 전체 스택 기동 가능
3. `db` 컨테이너 healthcheck 통과
4. `db-bootstrap`가 외부 DB에서 내부 DB로 데이터 복제 후 `Exited (0)` 종료
5. `backend`가 내부 `db`를 사용해 API 응답 가능
6. `frontend``backend`를 프록시해 8080 기준 화면/API 동작 가능
7. 내부 MySQL에 실데이터 적재 확인
즉, 현재 Git에 올라간 상태만으로도 WSL2와 외부 원본 DB 정보만 있으면 지금과 같은 수준의 Docker 실행 재현이 가능하다.
---
## 15. 현재 구조의 한계와 다음 단계
현재 구조는 충분히 시연 가능하고 개발 재현도 가능하지만, 다음은 아직 별도 작업이 필요하다.
1. 운영형 정적 배포 구조 전환
2. 외부 DB 없이도 완전 독립 실행 가능한 정식 dump/backup 체계
3. `.env.example` 정리
4. DB bootstrap 전용 계정/권한 최소화
5. 장기적으로 `map_config.json` 파일 저장 정책 정리
하지만 "현재 저장소만으로 지금과 같은 Docker 실행 상태 재현"이라는 목표는 이미 충족한다.
---
## 16. 빠른 실행 요약
가장 짧게 요약하면 다음 순서다.
1. `.env`에 외부 원본 MySQL 접속 정보를 넣는다.
2. WSL2 Ubuntu와 WSL 내부 Docker가 살아 있는지 확인한다.
3. `start_docker_wsl.ps1`를 실행한다.
4. `itam-db-bootstrap``Exited (0)`인지 확인한다.
5. `http://localhost:3000/api/assets/master``http://localhost:8080/api/assets/master`가 모두 200인지 확인한다.
6. 브라우저에서 `http://localhost:8080`을 열어 데이터가 보이는지 확인한다.
이 순서대로 진행하면 현재 저장소 기준 Dockerized ITAM 시연 환경을 재현할 수 있다.
---
## 17. 2026-06-16 최신 정정
이 문서의 상단 본문은 한동안 사용했던 `내부 db + db-bootstrap` 구조를 기준으로 작성됐다. 하지만 오늘 기준 현재 저장소의 실제 `docker-compose.yaml`은 다시 `무상태 앱 컨테이너 + 외부 DB` 구조로 되돌아가 있다.
따라서 현재 시점의 정답 아키텍처는 아래다.
1. `backend` 컨테이너
2. `frontend` 컨테이너
3. 외부 MySQL DB
현재는 더 이상 아래 항목이 없다.
1. `db` 서비스 없음
2. `db-bootstrap` 서비스 없음
3. `itam_mysql_data` 볼륨 없음
### 17.1 현재 실제 `docker-compose.yaml` 기준 backend 동작
현재 backend는 `.env`의 외부 DB 접속 정보를 그대로 사용한다.
즉, 아래 환경변수 매핑이 현재 기준이다.
1. `DB_HOST: ${DB_HOST}`
2. `DB_PORT: ${DB_PORT}`
3. `DB_USER: ${DB_USER}`
4. `DB_PASS: ${DB_PASS}`
5. `DB_NAME: ${DB_NAME}`
`PORT: 3000`만 Compose에서 고정한다.
### 17.2 현재 실제 기동 구조
현재 스택 기동 순서는 단순하다.
1. `backend` 기동
2. `frontend` 기동
3. backend는 외부 DB에 직접 접속
4. frontend는 `http://backend:3000`으로 프록시
즉, 현재는 DB 컨테이너 초기화 단계나 bootstrap 단계가 존재하지 않는다.
### 17.3 현재 기준 첫 실행 체크리스트
오늘 기준으로는 아래 순서가 맞다.
1. `.env`에 외부 DB 접속 정보 입력
2. `start_docker_wsl.ps1` 또는 `start_docker_wsl.bat` 실행
3. `http://localhost:3000/api/assets/master`가 200인지 확인
4. `http://localhost:8080/api/assets/master`가 200인지 확인
5. 브라우저에서 `http://localhost:8080` 접속 후 데이터 표시 확인
### 17.4 이 문서에서 현재 유효한 부분과 과거 이력 부분
현재도 그대로 유효한 내용은 아래다.
1. WSL2 기반 실행 방식
2. `start_docker_wsl.ps1` / `stop_docker_wsl.ps1` 사용 방식
3. `server.js`에서 Compose 환경변수가 `.env`보다 우선되도록 `dotenv.config()`를 유지해야 한다는 점
4. `vite.config.ts`에서 프록시 타깃을 환경변수화해야 한다는 점
현재는 과거 이력으로만 읽어야 하는 내용은 아래다.
1. 내부 `db` 서비스 설명
2. `db-bootstrap` 설명
3. `itam_mysql_data` 볼륨 설명
4. 내부 DB 재초기화 절차
5. 내부 테이블 확인 절차
### 17.5 현재 최종 한 줄 요약
오늘 날짜 기준 현재 저장소의 실사용 Compose 구조는 `frontend + backend + external DB`이며, 이전의 내부 DB/bootstrap 구조는 역사적으로 한 번 사용했던 임시 해결책으로만 남아 있다.

View File

@@ -1,48 +0,0 @@
services:
backend:
build:
context: .
dockerfile: Dockerfile.backend
container_name: itam-backend
working_dir: /app
env_file:
- .env
environment:
DB_HOST: ${DB_HOST}
DB_PORT: ${DB_PORT}
DB_USER: ${DB_USER}
DB_PASS: ${DB_PASS}
DB_NAME: ${DB_NAME}
PORT: 3000
ports:
- "3000:3000"
volumes:
- ./:/app
- backend_node_modules:/app/node_modules
- ./uploads:/app/uploads
- ./map_config.json:/app/map_config.json
command: npm run server
restart: unless-stopped
frontend:
build:
context: .
dockerfile: Dockerfile.frontend
container_name: itam-frontend
working_dir: /app
depends_on:
- backend
environment:
CHOKIDAR_USEPOLLING: "true"
VITE_DEV_PROXY_TARGET: http://backend:3000
ports:
- "8080:8080"
volumes:
- ./:/app
- frontend_node_modules:/app/node_modules
command: npm run dev -- --host 0.0.0.0
restart: unless-stopped
volumes:
backend_node_modules:
frontend_node_modules:

View File

@@ -1,16 +0,0 @@
# MySQL init directory
This directory is kept as a legacy hook for file-based MySQL initialization.
Current production path in this repository is not file-based import.
The live Docker flow uses the `db-bootstrap` service in `docker-compose.yaml` to stream data from the external source DB into the internal `db` container.
Use this directory only if you intentionally switch back to `docker-entrypoint-initdb.d` style initialization.
If you do that, typical naming would be:
- `01_schema.sql`
- `02_seed.sql`
- or a single `01_itam_dump.sql`
Remember that files in this directory are executed automatically by the MySQL container only on the first initialization of the data volume.

View File

@@ -1,330 +0,0 @@
# ITAM 도커라이징 작업 태스크 정리
## 1. 문서 목적
이 문서는 ITAM 자산관리 시스템의 도커라이징 작업을 실제 실행 단위로 쪼개서 정리한 태스크 문서다.
이 문서의 목표는 아래와 같다.
1. 내일까지 보여줄 시연 범위를 기준으로 우선순위를 정한다.
2. 시연용 작업과 운영형 전환 작업을 분리한다.
3. 개발 담당자가 바로 실행할 수 있는 체크리스트를 제공한다.
관련 배경과 구조 분석은 [doc_readme.md](c:/Users/user/Desktop/안건%20파일/itam/doc_readme.md) 문서를 기준으로 한다.
현재 구현/검증 상태:
- `Dockerfile.frontend` 생성 완료
- `Dockerfile.backend` 생성 완료
- `docker-compose.yaml` 생성 완료
- `.dockerignore` 생성 완료
- WSL2 Ubuntu에서 `docker compose up --build -d` 검증 완료
- frontend 8080 응답 확인 완료
- backend `/api/assets/master` 응답 확인 완료
- 현재 DB는 external MySQL 기준이며, DB 컨테이너 추가 작업은 다음 단계로 남아 있음
## 2. 이번 작업의 최우선 목표
이번 도커라이징의 1차 목표는 "운영 배포 완료"가 아니라 아래 상태를 재현하는 것이다.
1. frontend 컨테이너가 정상 기동한다.
2. backend 컨테이너가 정상 기동한다.
3. backend가 기존 외부 MySQL 또는 MySQL 컨테이너에 정상 연결된다.
4. 브라우저에서 화면이 열린다.
5. 핵심 API 호출이 정상 동작한다.
6. 업로드 저장 경로가 유지된다.
7. 필요 시 DB까지 함께 포함된 재현 가능한 스택을 제공한다.
## 3. 작업 범위 구분
### 3.1 이번 시연 범위에 포함
- Dockerfile.frontend 초안 작성
- Dockerfile.backend 초안 작성
- docker-compose.yaml 작성
- `.dockerignore` 작성
- MySQL 컨테이너 추가 설계
- 초기 SQL dump 또는 init SQL 적재 방식 정의
- `uploads` 볼륨 처리
- `map_config.json` 영속성 처리 방식 반영
- 컨테이너 기동 및 접속 확인
- 핵심 API 및 화면 확인
### 3.2 이번 시연 범위에서 제외
- DB 전체 마이그레이션 자동화
- nginx 기반 운영 배포 구조
- 단일 이미지 운영 구조 전환
- CI/CD 연계
## 4. 선행 확인 태스크
아래 태스크는 실제 Docker 파일 작성 전에 먼저 확인해야 한다.
### Task 1. 외부 MySQL 접근 가능 여부 확인
- 목적: 컨테이너에서 외부 DB 접속이 가능한지 확인
- 확인 항목:
- DB_HOST 접근 가능 여부
- DB_PORT 3306 접속 가능 여부
- 계정 권한 정상 여부
- 완료 기준:
- backend 컨테이너 기준 DB 연결 에러가 발생하지 않음
### Task 2. 기준 스키마 상태 확인
- 목적: 현재 앱이 요구하는 테이블 구조가 실제 DB와 맞는지 확인
- 확인 항목:
- `asset_core`
- `asset_spec`
- `asset_location`
- `asset_remote`
- `asset_history`
- `hardware_components_master`
- `job_spec_standards`
- 완료 기준:
- `/api/assets/master` 호출 시 쿼리 에러가 발생하지 않음
### Task 3. 파일 영속성 대상 확인
- 목적: 컨테이너 재시작 이후에도 유지되어야 할 파일/폴더 식별
- 대상:
- `uploads`
- `map_config.json`
- 완료 기준:
- 볼륨 설계 대상이 명확하게 문서화됨
### Task 4. DB 기준 데이터 소스 확정
- 목적: MySQL 컨테이너 최초 기동 시 어떤 데이터로 초기화할지 결정
- 선택지:
- 기존 사내 DB에서 추출한 SQL dump 사용
- 정리된 스키마 SQL + seed SQL 사용
- 수동 import 절차 사용
- 완료 기준:
- `docker/mysql/init` 기준 적재 전략 또는 수동 복원 절차가 확정됨
## 5. 시연용 도커라이징 태스크
### Task 5. 프런트 Dockerfile 작성
- 목적: Vite 개발 서버를 컨테이너에서 구동
- 작업 내용:
- Node 20 계열 이미지 사용
- `package*.json` 복사 후 `npm install`
- 8080 포트 노출
- `npm run dev -- --host 0.0.0.0` 실행
- 산출물:
- `Dockerfile.frontend`
- 완료 기준:
- 컨테이너에서 8080 포트가 정상 listen 상태가 됨
### Task 6. 백엔드 Dockerfile 작성
- 목적: Express API 서버를 컨테이너에서 구동
- 작업 내용:
- Node 20 계열 이미지 사용
- `package*.json` 복사 후 `npm install`
- 3000 포트 노출
- `npm run server` 실행
- 산출물:
- `Dockerfile.backend`
- 완료 기준:
- 컨테이너에서 3000 포트가 정상 listen 상태가 됨
### Task 7. MySQL Docker 구성 추가
- 목적: DB까지 포함한 재현 가능한 스택 구성
- 작업 내용:
- `mysql:8.0` 서비스 정의
- `MYSQL_DATABASE`, `MYSQL_USER`, `MYSQL_PASSWORD` 설정
- utf8mb4 문자셋 옵션 반영
- MySQL 데이터 volume 연결
- 초기 SQL 적재용 `docker/mysql/init` 디렉터리 설계
- 산출물:
- `docker-compose.yaml``db` 서비스 또는 별도 DB compose 확장안
- 완료 기준:
- MySQL 컨테이너가 정상 기동하고 3306 포트에서 응답 가능
### Task 8. backend DB 연결 전환
- 목적: backend가 external MySQL 대신 DB 컨테이너를 바라보도록 변경
- 작업 내용:
- `DB_HOST``db`로 전환
- 필요 시 `.env.docker` 또는 compose 내부 환경변수 사용
- backend `depends_on`에 db 추가
- 산출물:
- DB 컨테이너용 backend 환경 정의
- 완료 기준:
- backend 로그에서 DB 연결 성공 확인
### Task 9. docker-compose.yaml 확장
- 목적: frontend/backend를 함께 기동
- 작업 내용:
- frontend 서비스 정의
- backend 서비스 정의
- db 서비스 정의
- 포트 매핑 추가
- `.env` 또는 docker 전용 환경변수 연결
- MySQL 데이터 볼륨 연결
- `uploads` 볼륨 연결
- `map_config.json` 처리 방식 반영
- 산출물:
- `docker-compose.yaml`
- 완료 기준:
- `docker compose up --build` 한 번으로 세 서비스가 모두 올라옴
### Task 10. `.dockerignore` 작성
- 목적: 불필요한 빌드 컨텍스트 제외
- 제외 권장 항목:
- `node_modules`
- `dist`
- `build`
- `.git`
- `uploads`
- `*.xlsx`
- 산출물:
- `.dockerignore`
- 완료 기준:
- 이미지 빌드 컨텍스트가 과도하게 커지지 않음
## 6. 시연 검증 태스크
### Task 11. WSL 컨테이너 기동 검증
- 실행 명령:
```bash
powershell -ExecutionPolicy Bypass -File .\start_docker_wsl.ps1
```
- 확인 항목:
- frontend 로그 에러 여부
- backend 로그 에러 여부
- db 로그 에러 여부
- backend와 db 연결 성공 여부
- 완료 기준:
- 세 컨테이너 모두 종료 없이 유지됨
### Task 12. 웹 접속 검증
- 확인 항목:
- `http://localhost:8080` 접속 가능 여부
- 첫 화면 로딩 여부
- 콘솔 에러 여부
- 완료 기준:
- 브라우저에서 초기 화면이 정상 표시됨
### Task 13. API 검증
- 확인 항목:
- `http://localhost:3000/api/assets/master`
- 프런트에서 `/api/assets/master` 호출 정상 여부
- 완료 기준:
- 200 응답 또는 정상 데이터 응답 확인
### Task 14. DB 초기 데이터 검증
- 확인 항목:
- MySQL 컨테이너 내부에 목표 DB가 생성되었는지
- 기준 테이블이 존재하는지
- 샘플 데이터 또는 실데이터가 적재되었는지
- 완료 기준:
- backend가 기대하는 최소 테이블과 데이터가 실제로 조회됨
### Task 15. 업로드/파일 저장 검증
- 확인 항목:
- `/api/upload` 호출 정상 여부
- 업로드 파일이 `uploads`에 실제 저장되는지
- `map_config.json` 수정 내용이 유지되는지
- 완료 기준:
- 컨테이너 재시작 후에도 저장 데이터가 유지됨
## 7. 시연 후 후속 태스크
### Task 16. 운영형 프런트 배포 구조 전환
- 목표: Vite dev server 대신 정적 빌드 기반 구조로 전환
- 후보:
- nginx 정적 서빙
- Express 정적 서빙
### Task 17. DB 초기화/마이그레이션 전략 통합
- 목표: 기준 스키마와 실행 순서를 단일 정책으로 통일
- 필요 작업:
- 기준 스키마 선정
- 초기화 스크립트 확정
- 마이그레이션 순서 정의
### Task 18. `.env.example` 및 배포 환경 분리
- 목표: 민감정보를 저장소에서 분리하고 배포별 설정 체계화
### Task 19. 운영 볼륨 및 백업 전략 정리
- 목표: 업로드 파일과 설정 파일, MySQL 데이터의 장기 보존 정책 정리
### Task 20. DB 백업/복원 절차 문서화
- 목표: 컨테이너 DB를 기준으로 dump/restore 절차를 문서화
## 8. 우선순위 정리
### P0: 내일까지 반드시 필요한 작업
1. Task 1. 외부 MySQL 접근 가능 여부 확인
2. Task 2. 기준 스키마 상태 확인
3. Task 4. DB 기준 데이터 소스 확정
4. Task 7. MySQL Docker 구성 추가
5. Task 8. backend DB 연결 전환
6. Task 9. docker-compose.yaml 확장
7. Task 11. WSL 컨테이너 기동 검증
8. Task 12. 웹 접속 검증
9. Task 13. API 검증
10. Task 14. DB 초기 데이터 검증
### P1: 시연 안정화를 위해 권장되는 작업
1. Task 3. 파일 영속성 대상 확인
2. Task 10. `.dockerignore` 작성
3. Task 15. 업로드/파일 저장 검증
### P2: 시연 이후 진행할 작업
1. Task 16. 운영형 프런트 배포 구조 전환
2. Task 17. DB 초기화/마이그레이션 전략 통합
3. Task 18. `.env.example` 및 배포 환경 분리
4. Task 19. 운영 볼륨 및 백업 전략 정리
5. Task 20. DB 백업/복원 절차 문서화
## 9. 개발자용 최종 작업 순서 제안
개발 담당자에게는 아래 순서로 진행하라고 전달하면 된다.
1. 외부 DB 연결 가능 여부부터 확인
2. 현재 DB 스키마가 앱 요구사항과 맞는지 확인
3. DB 기준 dump 또는 init SQL 확보
4. MySQL 컨테이너 구성 추가
5. backend의 DB 연결 대상을 `db`로 전환
6. WSL에서 `docker compose config` 확인
7. WSL에서 컨테이너 기동 테스트
8. 웹 접속 및 API 확인
9. 업로드 및 파일 영속성 확인
10. 시연 완료 후 운영형 구조로 분리 작업 진행
## 10. 완료 판단 기준
이번 도커라이징 1차 작업은 아래 조건을 만족하면 완료로 본다.
1. `docker compose up --build`로 프런트, 백엔드, DB가 모두 기동한다.
2. 브라우저에서 8080 화면이 열린다.
3. `/api/assets/master`가 정상 응답한다.
4. backend가 DB 컨테이너와 정상 연결된다.
5. DB 초기 테이블과 데이터가 기대 상태로 적재된다.
6. `uploads`, `map_config.json`, MySQL 데이터가 재시작 후에도 유지된다.
이 문서는 실제 구현 작업의 체크리스트로 사용한다.

63
fix_all_dates.cjs Normal file
View File

@@ -0,0 +1,63 @@
const mysql = require('mysql2/promise');
require('dotenv').config({ override: true });
function formatToYYYYMMDD(val) {
if (!val) return null;
const s = String(val).trim().replace(/[^0-9]/g, '');
if (s.length === 8) {
// YYYYMMDD
return `${s.substring(0, 4)}-${s.substring(4, 6)}-${s.substring(6, 8)}`;
} else if (s.length === 6) {
// YYMMDD -> Assume 20XX
const year = parseInt(s.substring(0, 2)) > 50 ? '19' + s.substring(0, 2) : '20' + s.substring(0, 2);
return `${year}-${s.substring(2, 4)}-${s.substring(4, 6)}`;
}
// Try to split by dots or slashes if original had them
const parts = String(val).trim().split(/[\.\-\/]/);
if (parts.length === 3) {
let y = parts[0];
let m = parts[1].padStart(2, '0');
let d = parts[2].padStart(2, '0');
if (y.length === 2) y = '20' + y;
if (y.length === 4 && m.length <= 2 && d.length <= 2) {
return `${y}-${m}-${d}`;
}
}
return val; // Return as is if format is unknown
}
(async () => {
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306')
});
const connection = await pool.getConnection();
try {
const [rows] = await connection.query('SELECT id, purchase_date FROM asset_core WHERE purchase_date IS NOT NULL AND purchase_date != \'\'');
console.log(`Found ${rows.length} rows to check.`);
let updatedCount = 0;
for (const row of rows) {
const original = row.purchase_date;
const formatted = formatToYYYYMMDD(original);
if (formatted !== original && /^\d{4}-\d{2}-\d{2}$/.test(formatted)) {
await connection.query('UPDATE asset_core SET purchase_date = ? WHERE id = ?', [formatted, row.id]);
updatedCount++;
}
}
console.log(`✅ Successfully updated ${updatedCount} rows to YYYY-MM-DD format.`);
} catch (err) {
console.error('❌ Error during date migration:', err);
} finally {
connection.release();
await pool.end();
}
})();

View File

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 388 KiB

View File

@@ -0,0 +1,354 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Center Chair Map (View Only)</title>
<style>
:root {
--ink: #152330;
--muted: #627286;
--paper: rgba(255,255,255,0.86);
--line: rgba(21,35,48,0.1);
--accent: #0f766e;
--bg: #edf2f6;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
overflow: hidden;
}
.page {
min-height: 100vh;
padding: 0;
}
.shell {
min-height: 100vh;
}
.panel {
border-radius: 0;
border: none;
background: transparent;
backdrop-filter: none;
box-shadow: none;
}
.viewer {
position: relative;
overflow: hidden;
min-height: 100vh;
}
.viewer-head {
position: absolute;
top: 16px;
left: 16px;
z-index: 2;
pointer-events: none;
}
.chip {
padding: 10px 12px;
border-radius: 16px;
background: rgba(255,255,255,0.82);
border: 1px solid rgba(255,255,255,0.94);
color: var(--muted);
font-size: 13px;
font-weight: 700;
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
display: inline-block;
}
.viewer-actions {
position: absolute;
left: 16px;
top: 64px;
z-index: 2;
}
button {
border: none;
border-radius: 999px;
padding: 10px 14px;
font: inherit;
font-weight: 700;
cursor: pointer;
color: white;
background: linear-gradient(135deg, #0f766e, #115e59);
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
}
canvas {
width: 100vw;
height: 100vh;
display: block;
cursor: grab;
}
canvas.dragging { cursor: grabbing; }
</style>
</head>
<body>
<div class="page">
<div class="shell">
<main class="panel viewer">
<div class="viewer-head">
<div class="chip" id="scale-chip"></div>
</div>
<div class="viewer-actions">
<button type="button" id="fit-btn">전체 맞춤</button>
</div>
<canvas id="canvas"></canvas>
</main>
</div>
</div>
<script src="./center_chair_people_payload.js?v=20260403a"></script>
<script>
const DATA = window.CHAIR_MAP_DATA;
function decodeSegments(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return new Int32Array(bytes.buffer);
}
const bgTileRanges = DATA.bgTileRanges;
const bgSegValues = decodeSegments(DATA.bgSegsB64);
const chairSegValues = decodeSegments(DATA.chairSegsB64);
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
key, name, kind, start, count
}));
const meta = DATA.meta;
const world = meta.headerBounds;
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const scaleChip = document.getElementById("scale-chip");
// --- Added for Point Picking & Marker ---
const params = new URLSearchParams(window.location.search);
let markerX = params.get('markerX') ? parseFloat(params.get('markerX')) : null;
let markerY = params.get('markerY') ? parseFloat(params.get('markerY')) : null;
const chairGeometry = chairs.map((chair) => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const path = new Path2D();
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
const offset = i * 4;
const x1 = chairSegValues[offset] / 10;
const y1 = chairSegValues[offset + 1] / 10;
const x2 = chairSegValues[offset + 2] / 10;
const y2 = chairSegValues[offset + 3] / 10;
path.moveTo(x1, y1);
path.lineTo(x2, y2);
minX = Math.min(minX, x1, x2);
minY = Math.min(minY, y1, y2);
maxX = Math.max(maxX, x1, x2);
maxY = Math.max(maxY, y1, y2);
}
return { ...chair, minX, minY, maxX, maxY, path };
});
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
let pixelRatio = window.devicePixelRatio || 1;
let dragging = false;
let dragStart = null;
let rafPending = false;
function resize() {
pixelRatio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * pixelRatio);
canvas.height = Math.round(rect.height * pixelRatio);
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
fit();
}
function fit() {
const rect = canvas.getBoundingClientRect();
const width = world.maxX - world.minX;
const height = world.maxY - world.minY;
const pad = 36;
const scaleX = (rect.width - pad * 2) / width;
const scaleY = (rect.height - pad * 2) / height;
camera.scale = Math.min(scaleX, scaleY);
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
requestDraw();
}
function drawGrid(width, height) {
ctx.save();
ctx.strokeStyle = "rgba(21,35,48,0.05)";
ctx.lineWidth = 1;
for (let x = 120; x < width; x += 120) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 120; y < height; y += 120) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
ctx.restore();
}
function worldToScreen(x, y) {
return {
x: x * camera.scale + camera.offsetX,
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
};
}
function screenToWorld(x, y) {
return {
x: (x - camera.offsetX) / camera.scale,
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
};
}
function requestDraw() {
if (rafPending) return;
rafPending = true;
window.requestAnimationFrame(() => {
rafPending = false;
draw();
});
}
function applyWorldTransform() {
ctx.setTransform(
pixelRatio * camera.scale,
0,
0,
-pixelRatio * camera.scale,
pixelRatio * camera.offsetX,
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
);
}
function draw() {
const rect = canvas.getBoundingClientRect();
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.clearRect(0, 0, rect.width, rect.height);
drawGrid(rect.width, rect.height);
const viewA = screenToWorld(0, rect.height);
const viewB = screenToWorld(rect.width, 0);
const viewMinX = Math.min(viewA.x, viewB.x);
const viewMaxX = Math.max(viewA.x, viewB.x);
const viewMinY = Math.min(viewA.y, viewB.y);
const viewMaxY = Math.max(viewA.y, viewB.y);
ctx.save();
applyWorldTransform();
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
ctx.lineWidth = 1 / camera.scale;
const tileSize = meta.backgroundTileSize;
const tileMinX = Math.floor(viewMinX / tileSize);
const tileMaxX = Math.floor(viewMaxX / tileSize);
const tileMinY = Math.floor(viewMinY / tileSize);
const tileMaxY = Math.floor(viewMaxY / tileSize);
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
const range = bgTileRanges[`${tx},${ty}`];
if (!range) continue;
const start = range[0];
const count = range[1];
for (let i = start; i < start + count; i += 1) {
const offset = i * 4;
const x1 = bgSegValues[offset] / 10;
const y1 = bgSegValues[offset + 1] / 10;
const x2 = bgSegValues[offset + 2] / 10;
const y2 = bgSegValues[offset + 3] / 10;
if (Math.max(x1, x2) < viewMinX || Math.min(x1, x2) > viewMaxX ||
Math.max(y1, y2) < viewMinY || Math.min(y1, y2) > viewMaxY) continue;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
}
}
ctx.lineWidth = 1.35 / camera.scale;
ctx.lineCap = "round";
ctx.lineJoin = "round";
ctx.strokeStyle = "rgba(21, 149, 142, 0.8)";
for (const chair of chairGeometry) {
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
ctx.stroke(chair.path);
}
// --- Draw Marker ---
if (markerX !== null && markerY !== null) {
ctx.beginPath();
ctx.arc(markerX, markerY, 50 / camera.scale, 0, Math.PI * 2);
ctx.fillStyle = "rgba(220, 38, 38, 0.8)";
ctx.fill();
ctx.strokeStyle = "#fff";
ctx.lineWidth = 10 / camera.scale;
ctx.stroke();
}
ctx.restore();
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
}
canvas.addEventListener("pointerdown", (event) => {
dragging = true;
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
canvas.classList.add("dragging");
});
window.addEventListener("pointerup", (event) => {
if (dragging && dragStart) {
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
if (move < 4) {
const rect = canvas.getBoundingClientRect();
const mx = event.clientX - rect.left;
const my = event.clientY - rect.top;
const worldPos = screenToWorld(mx, my);
markerX = worldPos.x;
markerY = worldPos.y;
requestDraw();
// Notify parent window
window.parent.postMessage({
type: 'PICK_LOCATION',
x: markerX.toFixed(2),
y: markerY.toFixed(2)
}, '*');
}
}
dragging = false;
dragStart = null;
canvas.classList.remove("dragging");
});
window.addEventListener("pointermove", (event) => {
if (dragging && dragStart) {
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
requestDraw();
}
});
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = event.clientX - rect.left;
const my = event.clientY - rect.top;
const before = screenToWorld(mx, my);
const factor = event.deltaY < 0 ? 1.08 : 0.92;
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
const after = worldToScreen(before.x, before.y);
camera.offsetX += mx - after.x;
camera.offsetY += my - after.y;
requestDraw();
}, { passive: false });
document.getElementById("fit-btn").addEventListener("click", fit);
window.addEventListener("resize", resize);
resize();
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,931 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>center chair people map</title>
<style>
:root {
--ink: #152330;
--muted: #627286;
--paper: rgba(255,255,255,0.86);
--line: rgba(21,35,48,0.1);
--accent: #0f766e;
--bg: #edf2f6;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
}
.page {
min-height: 100vh;
padding: 0;
}
.shell {
min-height: 100vh;
}
.panel {
border-radius: 0;
border: none;
background: transparent;
backdrop-filter: none;
box-shadow: none;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
button {
border: none;
border-radius: 999px;
padding: 10px 14px;
font: inherit;
font-weight: 700;
cursor: pointer;
color: white;
background: linear-gradient(135deg, #0f766e, #115e59);
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
}
button.alt {
color: var(--ink);
background: rgba(255,255,255,0.9);
border: 1px solid var(--line);
box-shadow: none;
}
.viewer {
position: relative;
overflow: hidden;
min-height: 100vh;
}
.viewer-head {
position: absolute;
top: 16px;
left: 16px;
right: 16px;
z-index: 2;
display: flex;
justify-content: space-between;
gap: 12px;
pointer-events: none;
}
.chip {
padding: 10px 12px;
border-radius: 16px;
background: rgba(255,255,255,0.82);
border: 1px solid rgba(255,255,255,0.94);
color: var(--muted);
font-size: 13px;
font-weight: 700;
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
}
.viewer-actions {
position: absolute;
left: 16px;
top: 64px;
z-index: 2;
display: flex;
gap: 8px;
}
.mapper {
position: absolute;
top: 76px;
left: 50%;
transform: translateX(-50%);
width: min(94vw, 1320px);
max-height: min(56vh, 560px);
overflow: hidden;
z-index: 4;
border-radius: 20px;
background: rgba(234, 239, 247, 0.95);
border: 1px solid rgba(101, 119, 146, 0.22);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
display: flex;
flex-direction: column;
backdrop-filter: blur(6px);
}
.hidden-off {
display: none !important;
}
.mapper-head {
padding: 10px 14px;
border-bottom: 1px solid rgba(101,119,146,0.18);
font-size: 12px;
color: #51607a;
font-weight: 700;
line-height: 1.35;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: rgba(255,255,255,0.6);
}
.mapper-head strong {
display: block;
color: #17243b;
font-size: 20px;
margin-bottom: 2px;
}
.mapper-head .alt {
padding: 8px 10px;
font-size: 12px;
white-space: nowrap;
}
.org-chart {
margin: 0;
padding: 14px;
overflow: auto;
display: grid;
gap: 12px;
}
.org-top {
margin: 0 auto;
width: min(100%, 420px);
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(67, 84, 118, 0.25);
background: #fff;
}
.org-top-title {
background: #1e2f4d;
color: #fff;
text-align: center;
font-size: 34px;
font-weight: 800;
line-height: 1.1;
padding: 16px 12px;
letter-spacing: -0.03em;
}
.org-top-members {
padding: 10px;
display: grid;
gap: 6px;
background: rgba(255,255,255,0.95);
}
.org-teams {
display: grid;
grid-template-columns: repeat(7, minmax(160px, 1fr));
gap: 10px;
align-items: start;
}
.org-team {
border: 1px solid rgba(110, 126, 152, 0.25);
border-radius: 10px;
overflow: hidden;
background: rgba(255,255,255,0.95);
min-width: 0;
}
.org-team h4 {
margin: 0;
padding: 9px 10px;
font-size: 14px;
color: #21324e;
font-weight: 800;
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
background: rgba(240, 245, 252, 0.96);
}
.org-members {
padding: 7px;
display: grid;
gap: 6px;
}
.org-person {
border: 1px solid rgba(116, 133, 161, 0.25);
background: rgba(255,255,255,0.95);
border-radius: 8px;
padding: 6px 8px;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
min-width: 0;
}
.org-person.active {
border-color: rgba(15,118,110,0.6);
background: rgba(15,118,110,0.11);
}
.org-person.assigned {
border-color: rgba(37,99,235,0.5);
background: rgba(37,99,235,0.1);
}
.org-person strong {
display: block;
font-size: 13px;
line-height: 1.3;
color: #15233a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.org-person small {
display: block;
color: #5a6a86;
font-size: 11px;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 980px) {
.mapper {
top: 72px;
width: min(96vw, 920px);
max-height: 58vh;
}
.viewer-actions {
top: 64px;
left: 12px;
right: 12px;
flex-wrap: wrap;
}
.mapper-head strong {
font-size: 16px;
}
.org-top-title {
font-size: 24px;
}
.org-teams {
grid-template-columns: repeat(3, minmax(150px, 1fr));
}
}
canvas {
width: 100%;
height: 100%;
display: block;
cursor: grab;
}
canvas.dragging { cursor: grabbing; }
.tooltip {
position: absolute;
min-width: 170px;
padding: 12px 14px;
border-radius: 16px;
background: rgba(17,24,39,0.94);
color: white;
pointer-events: none;
opacity: 0;
transform: translate(12px, 12px);
transition: opacity 120ms ease;
z-index: 3;
}
.tooltip.visible { opacity: 1; }
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
</style>
</head>
<body>
<div class="page">
<div class="shell">
<main class="panel viewer">
<div class="viewer-head">
<div class="chip" id="scale-chip"></div>
<div class="chip" id="hover-chip">chair hover: none</div>
</div>
<div class="viewer-actions">
<button type="button" id="fit-btn">전체 맞춤</button>
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
</div>
<aside class="mapper hidden-off">
<div class="mapper-head">
<div id="mapper-status">
<strong>조직 현황</strong>
<span>선택 인원 없음</span>
</div>
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
</div>
<div class="org-chart" id="org-chart"></div>
</aside>
<canvas id="canvas"></canvas>
<div class="tooltip" id="tooltip"></div>
</main>
</div>
</div>
<script src="./center_chair_people_payload.js?v=20260403a"></script>
<script>
const DATA = window.CHAIR_MAP_DATA;
function decodeSegments(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return new Int32Array(bytes.buffer);
}
const bgTileRanges = DATA.bgTileRanges;
const bgSegValues = decodeSegments(DATA.bgSegsB64);
const chairSegValues = decodeSegments(DATA.chairSegsB64);
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
key, name, kind, start, count
}));
const meta = DATA.meta;
const world = meta.headerBounds;
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const tooltip = document.getElementById("tooltip");
const scaleChip = document.getElementById("scale-chip");
const hoverChip = document.getElementById("hover-chip");
const STORAGE_KEY = "ptc-chair-selection";
const PEOPLE_STORAGE_KEY = "ptc-chair-people";
const ASSIGN_STORAGE_KEY = "ptc-chair-assignments";
const ACTIVE_PERSON_STORAGE_KEY = "ptc-chair-active-person";
const clearAssignBtn = document.getElementById("clear-assign-btn");
const orgChartEl = document.getElementById("org-chart");
const mapperStatus = document.getElementById("mapper-status");
// Prevent stale auto-highlights from previous sessions.
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
localStorage.removeItem(ASSIGN_STORAGE_KEY);
const placed = new Set();
let people = JSON.parse(localStorage.getItem(PEOPLE_STORAGE_KEY) || "[]");
let chairAssignments = {};
let activePersonId = null;
const ORG_TEMPLATE = {
top: {
name: "총괄기획실",
count: 53,
members: [
{ name: "장종찬", dept: "총괄기획실", title: "기획실장" },
{ name: "김원식", dept: "총괄기획실", title: "전무이사" },
],
},
teams: [
{ name: "경영기획팀", count: 6, members: ["김우진", "임민정", "국혜린", "최선아", "김윤재", "이미영"] },
{ name: "인재성장팀", count: 5, members: ["조태희", "최근혜", "류원준", "주안기", "정성호"] },
{ name: "ERP 기획팀", count: 5, members: ["류호성", "문형식", "최요제", "황대일", "이채봉"] },
{ name: "디자인기획팀", count: 17, members: ["신혜영", "정은혜", "김태식", "최예은", "채선영", "최영환", "윤봄이", "이예진", "허유나", "마희연", "김수현", "박지영", "권순호", "정두휘", "김정석", "정지윤", "양숙영"] },
{ name: "기술기획팀", count: 11, members: ["김원기", "홍아름", "이경민", "김혜인", "황동환", "최찬호", "이태훈", "김신지", "조찬영", "김용연", "한치영"] },
{ name: "협업증진팀", count: 3, members: ["성형일", "박주한", "한승민"] },
{ name: "솔루션통합팀", count: 4, members: ["권혁진", "염승호", "윤준수", "김지영"] },
],
};
const chairGeometry = chairs.map((chair) => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const path = new Path2D();
const hitSegments = new Float32Array(chair.count * 4);
let segCursor = 0;
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
const offset = i * 4;
const x1 = chairSegValues[offset] / 10;
const y1 = chairSegValues[offset + 1] / 10;
const x2 = chairSegValues[offset + 2] / 10;
const y2 = chairSegValues[offset + 3] / 10;
path.moveTo(x1, y1);
path.lineTo(x2, y2);
hitSegments[segCursor] = x1;
hitSegments[segCursor + 1] = y1;
hitSegments[segCursor + 2] = x2;
hitSegments[segCursor + 3] = y2;
segCursor += 4;
minX = Math.min(minX, x1, x2);
minY = Math.min(minY, y1, y2);
maxX = Math.max(maxX, x1, x2);
maxY = Math.max(maxY, y1, y2);
}
return {
...chair,
minX,
minY,
maxX,
maxY,
area: Math.max(1, (maxX - minX) * (maxY - minY)),
path,
hitSegments,
};
});
function renumberChairKeys(chairItems) {
if (!chairItems.length) return;
const heights = chairItems
.map((chair) => Math.max(1, chair.maxY - chair.minY))
.sort((a, b) => a - b);
const medianHeight = heights[Math.floor(heights.length / 2)] || 1;
const rowTolerance = Math.max(40, medianHeight * 0.9);
const sorted = [...chairItems].sort((a, b) => {
const ay = (a.minY + a.maxY) * 0.5;
const by = (b.minY + b.maxY) * 0.5;
if (Math.abs(by - ay) > rowTolerance) return by - ay; // top -> bottom
const ax = (a.minX + a.maxX) * 0.5;
const bx = (b.minX + b.maxX) * 0.5;
return ax - bx; // left -> right
});
sorted.forEach((chair, index) => {
chair.key = String(index + 1);
chair.seatNo = index + 1;
});
}
renumberChairKeys(chairGeometry);
const PICK_GRID_SIZE = 1800;
const chairPickGrid = new Map();
function pickGridKey(gx, gy) {
return `${gx},${gy}`;
}
chairGeometry.forEach((chair, index) => {
const minGX = Math.floor(chair.minX / PICK_GRID_SIZE);
const maxGX = Math.floor(chair.maxX / PICK_GRID_SIZE);
const minGY = Math.floor(chair.minY / PICK_GRID_SIZE);
const maxGY = Math.floor(chair.maxY / PICK_GRID_SIZE);
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const key = pickGridKey(gx, gy);
if (!chairPickGrid.has(key)) chairPickGrid.set(key, []);
chairPickGrid.get(key).push(index);
}
}
});
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
let pixelRatio = window.devicePixelRatio || 1;
let pointer = { x: 0, y: 0 };
let dragging = false;
let dragStart = null;
let hovered = null;
let rafPending = false;
function normalizePeople(raw) {
return raw
.map((person, index) => {
if (!person || !person.name) return null;
return {
id: person.id || `person-${index + 1}`,
name: String(person.name).trim(),
dept: String(person.dept || "").trim(),
title: String(person.title || "").trim(),
};
})
.filter(Boolean);
}
function createTemplatePeople() {
const generated = [];
let seq = 1;
ORG_TEMPLATE.top.members.forEach((member) => {
generated.push({
id: `org-${seq++}`,
name: member.name,
dept: member.dept,
title: member.title,
});
});
ORG_TEMPLATE.teams.forEach((team) => {
team.members.forEach((name) => {
generated.push({
id: `org-${seq++}`,
name,
dept: team.name,
title: "선임",
});
});
});
return generated;
}
people = normalizePeople(people);
const templateReady = people.some((person) => person.name === "장종찬" && person.dept === "총괄기획실");
if (!templateReady) {
people = createTemplatePeople();
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
const chairKeySet = new Set(chairGeometry.map((chair) => chair.key));
chairAssignments = Object.fromEntries(
Object.entries(chairAssignments).filter(([chairKey, personId]) => (
chairKeySet.has(chairKey) && people.some((person) => person.id === personId)
))
);
if (activePersonId && !people.some((person) => person.id === activePersonId)) activePersonId = null;
function persistPeople() {
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
function persistAssignments() {
localStorage.setItem(ASSIGN_STORAGE_KEY, JSON.stringify(chairAssignments));
}
function persistActivePerson() {
if (!activePersonId) localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
else localStorage.setItem(ACTIVE_PERSON_STORAGE_KEY, activePersonId);
}
function assignmentCount() {
return Object.keys(chairAssignments).length;
}
function getPersonById(id) {
return people.find((person) => person.id === id) || null;
}
function getChairByPerson(personId) {
for (const [chairKey, assignedPersonId] of Object.entries(chairAssignments)) {
if (assignedPersonId === personId) return chairKey;
}
return null;
}
function renderPeopleList() {
const activePerson = getPersonById(activePersonId);
const countText = `${assignmentCount()} / ${people.length} 매칭`;
mapperStatus.innerHTML = `<strong>조직 현황</strong><span>${activePerson ? `${activePerson.name} 선택됨` : "선택 인원 없음"} · ${countText}</span>`;
const findPerson = (dept, name) => people.find((person) => person.dept === dept && person.name === name) || null;
const personCard = (person, roleText) => {
if (!person) return "";
const chairKey = getChairByPerson(person.id);
const assignedClass = chairKey ? " assigned" : "";
const activeClass = person.id === activePersonId ? " active" : "";
return `
<article class="org-person${assignedClass}${activeClass}" data-person-id="${person.id}">
<strong>${person.name}</strong>
<small>${person.title || roleText || "-"}</small>
<small>${chairKey ? `좌석 ${chairKey}` : "좌석 미지정"}</small>
</article>
`;
};
const topHtml = ORG_TEMPLATE.top.members
.map((member) => personCard(findPerson(member.dept, member.name), member.title))
.join("");
const teamsHtml = ORG_TEMPLATE.teams.map((team) => {
const membersHtml = team.members
.map((name) => personCard(findPerson(team.name, name), "선임"))
.join("");
return `
<section class="org-team">
<h4>${team.name} (${team.count})</h4>
<div class="org-members">${membersHtml}</div>
</section>
`;
}).join("");
orgChartEl.innerHTML = `
<section class="org-top">
<div class="org-top-title">${ORG_TEMPLATE.top.name} (${ORG_TEMPLATE.top.count})</div>
<div class="org-top-members">${topHtml}</div>
</section>
<section class="org-teams">${teamsHtml}</section>
`;
}
function worldToScreen(x, y) {
return {
x: x * camera.scale + camera.offsetX,
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
};
}
function screenToWorld(x, y) {
return {
x: (x - camera.offsetX) / camera.scale,
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
};
}
function resize() {
pixelRatio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * pixelRatio);
canvas.height = Math.round(rect.height * pixelRatio);
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
fit();
}
function fit() {
const rect = canvas.getBoundingClientRect();
const width = world.maxX - world.minX;
const height = world.maxY - world.minY;
const pad = 36;
const scaleX = (rect.width - pad * 2) / width;
const scaleY = (rect.height - pad * 2) / height;
camera.scale = Math.min(scaleX, scaleY);
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
requestDraw();
}
function drawGrid(width, height) {
ctx.save();
ctx.strokeStyle = "rgba(21,35,48,0.05)";
ctx.lineWidth = 1;
for (let x = 120; x < width; x += 120) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 120; y < height; y += 120) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
ctx.restore();
}
function pickChair(screenX, screenY) {
const threshold = 12;
const pointerWorld = screenToWorld(screenX, screenY);
const thresholdWorld = threshold / camera.scale;
const thresholdWorldSq = thresholdWorld * thresholdWorld;
const minGX = Math.floor((pointerWorld.x - thresholdWorld) / PICK_GRID_SIZE);
const maxGX = Math.floor((pointerWorld.x + thresholdWorld) / PICK_GRID_SIZE);
const minGY = Math.floor((pointerWorld.y - thresholdWorld) / PICK_GRID_SIZE);
const maxGY = Math.floor((pointerWorld.y + thresholdWorld) / PICK_GRID_SIZE);
const candidateIndexes = [];
const seen = new Set();
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const candidates = chairPickGrid.get(pickGridKey(gx, gy));
if (!candidates) continue;
for (const index of candidates) {
if (seen.has(index)) continue;
seen.add(index);
candidateIndexes.push(index);
}
}
}
let best = null;
for (const index of candidateIndexes) {
const chair = chairGeometry[index];
if (
pointerWorld.x < chair.minX - thresholdWorld ||
pointerWorld.x > chair.maxX + thresholdWorld ||
pointerWorld.y < chair.minY - thresholdWorld ||
pointerWorld.y > chair.maxY + thresholdWorld
) continue;
let distSq = Infinity;
for (let i = 0; i < chair.hitSegments.length; i += 4) {
const x1 = chair.hitSegments[i];
const y1 = chair.hitSegments[i + 1];
const x2 = chair.hitSegments[i + 2];
const y2 = chair.hitSegments[i + 3];
const dx = x2 - x1;
const dy = y2 - y1;
const len2 = dx * dx + dy * dy;
let segDistSq;
if (len2 === 0) {
const px = pointerWorld.x - x1;
const py = pointerWorld.y - y1;
segDistSq = px * px + py * py;
} else {
let t = ((pointerWorld.x - x1) * dx + (pointerWorld.y - y1) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const lx = x1 + t * dx;
const ly = y1 + t * dy;
const px = pointerWorld.x - lx;
const py = pointerWorld.y - ly;
segDistSq = px * px + py * py;
}
if (segDistSq < distSq) distSq = segDistSq;
if (distSq <= thresholdWorldSq * 0.3) break;
}
if (distSq > thresholdWorldSq) continue;
const dist = Math.sqrt(distSq) * camera.scale;
if (!best) {
best = { chair, dist };
continue;
}
const distGap = dist - best.dist;
if (distGap < -0.75) {
best = { chair, dist };
continue;
}
if (Math.abs(distGap) <= 2) {
const areaGap = chair.area - best.chair.area;
if (areaGap < -1) {
best = { chair, dist };
continue;
}
if (
Math.abs(areaGap) <= 1 &&
chair.kind === "block" &&
best.chair.kind !== "block"
) {
best = { chair, dist };
}
}
}
return best ? best.chair : null;
}
function renderTooltip() {
if (!hovered) {
tooltip.classList.remove("visible");
hoverChip.textContent = "chair hover: none";
return;
}
hoverChip.textContent = `chair hover: ${hovered.name}`;
tooltip.innerHTML = `
<strong>${hovered.name}</strong>
<div>chair key: ${hovered.key}</div>
<div>${placed.has(hovered.key) ? "선택됨" : "클릭하면 선택"}</div>
<div>${chairAssignments[hovered.key] ? `배치: ${(getPersonById(chairAssignments[hovered.key]) || { name: "알수없음" }).name}` : "배치 인원 없음"}</div>
`;
tooltip.style.left = `${pointer.x + 14}px`;
tooltip.style.top = `${pointer.y + 14}px`;
tooltip.classList.add("visible");
}
function requestDraw() {
if (rafPending) return;
rafPending = true;
window.requestAnimationFrame(() => {
rafPending = false;
draw();
});
}
function applyWorldTransform() {
ctx.setTransform(
pixelRatio * camera.scale,
0,
0,
-pixelRatio * camera.scale,
pixelRatio * camera.offsetX,
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
);
}
function draw() {
const rect = canvas.getBoundingClientRect();
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.clearRect(0, 0, rect.width, rect.height);
drawGrid(rect.width, rect.height);
const viewA = screenToWorld(0, rect.height);
const viewB = screenToWorld(rect.width, 0);
const viewMinX = Math.min(viewA.x, viewB.x);
const viewMaxX = Math.max(viewA.x, viewB.x);
const viewMinY = Math.min(viewA.y, viewB.y);
const viewMaxY = Math.max(viewA.y, viewB.y);
ctx.save();
applyWorldTransform();
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
ctx.lineWidth = 1 / camera.scale;
const tileSize = meta.backgroundTileSize;
const tileMinX = Math.floor(viewMinX / tileSize);
const tileMaxX = Math.floor(viewMaxX / tileSize);
const tileMinY = Math.floor(viewMinY / tileSize);
const tileMaxY = Math.floor(viewMaxY / tileSize);
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
const range = bgTileRanges[`${tx},${ty}`];
if (!range) continue;
const start = range[0];
const count = range[1];
for (let i = start; i < start + count; i += 1) {
const offset = i * 4;
const x1 = bgSegValues[offset] / 10;
const y1 = bgSegValues[offset + 1] / 10;
const x2 = bgSegValues[offset + 2] / 10;
const y2 = bgSegValues[offset + 3] / 10;
if (
Math.max(x1, x2) < viewMinX ||
Math.min(x1, x2) > viewMaxX ||
Math.max(y1, y2) < viewMinY ||
Math.min(y1, y2) > viewMaxY
) continue;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
}
}
ctx.restore();
hovered = dragging ? null : pickChair(pointer.x, pointer.y);
ctx.save();
applyWorldTransform();
ctx.lineWidth = 1.45 / camera.scale;
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (const chair of chairGeometry) {
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
const active = hovered && hovered.key === chair.key;
const selected = placed.has(chair.key);
const assignedPersonId = chairAssignments[chair.key];
const activePersonChair = activePersonId && assignedPersonId === activePersonId;
const assigned = Boolean(assignedPersonId);
const baseWidth = chair.kind === "block" ? 1.45 : 1.35;
ctx.strokeStyle = activePersonChair
? "rgba(234, 179, 8, 1)"
: assigned
? "rgba(37, 99, 235, 0.98)"
: selected
? "rgba(220, 38, 38, 0.98)"
: active
? "rgba(15, 118, 110, 0.98)"
: chair.kind === "group"
? "rgba(16, 134, 149, 0.74)"
: "rgba(21, 149, 142, 0.8)";
ctx.lineWidth = (activePersonChair ? 2.8 : assigned ? 2.4 : selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;
ctx.stroke(chair.path);
}
ctx.restore();
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
renderTooltip();
}
function persistPlaced() {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));
}
canvas.addEventListener("pointerdown", (event) => {
dragging = true;
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
canvas.classList.add("dragging");
});
window.addEventListener("pointerup", (event) => {
if (dragging && dragStart) {
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
if (move < 4) {
const rect = canvas.getBoundingClientRect();
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
if (picked) {
if (placed.has(picked.key)) placed.delete(picked.key);
else placed.add(picked.key);
persistPlaced();
if (activePersonId) {
const currentChair = getChairByPerson(activePersonId);
if (chairAssignments[picked.key] === activePersonId) {
delete chairAssignments[picked.key];
} else {
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
chairAssignments[picked.key] = activePersonId;
}
persistAssignments();
renderPeopleList();
}
}
}
}
dragging = false;
dragStart = null;
canvas.classList.remove("dragging");
requestDraw();
});
window.addEventListener("pointermove", (event) => {
const rect = canvas.getBoundingClientRect();
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
if (dragging && dragStart) {
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
}
requestDraw();
});
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = event.clientX - rect.left;
const my = event.clientY - rect.top;
const before = screenToWorld(mx, my);
const factor = event.deltaY < 0 ? 1.08 : 0.92;
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
const after = worldToScreen(before.x, before.y);
camera.offsetX += mx - after.x;
camera.offsetY += my - after.y;
requestDraw();
}, { passive: false });
document.getElementById("fit-btn").addEventListener("click", fit);
document.getElementById("clear-btn").addEventListener("click", () => {
placed.clear();
persistPlaced();
requestDraw();
});
clearAssignBtn.addEventListener("click", () => {
chairAssignments = {};
persistAssignments();
renderPeopleList();
requestDraw();
});
orgChartEl.addEventListener("click", (event) => {
const item = event.target.closest(".org-person[data-person-id]");
if (!item) return;
const personId = item.getAttribute("data-person-id");
activePersonId = personId === activePersonId ? null : personId;
persistActivePerson();
renderPeopleList();
requestDraw();
});
window.addEventListener("resize", resize);
renderPeopleList();
resize();
</script>
</body>
</html>

View File

@@ -0,0 +1,932 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>center chair people map 6f</title>
<style>
:root {
--ink: #152330;
--muted: #627286;
--paper: rgba(255,255,255,0.86);
--line: rgba(21,35,48,0.1);
--accent: #0f766e;
--bg: #edf2f6;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
}
.page {
min-height: 100vh;
padding: 0;
}
.shell {
min-height: 100vh;
}
.panel {
border-radius: 0;
border: none;
background: transparent;
backdrop-filter: none;
box-shadow: none;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
button {
border: none;
border-radius: 999px;
padding: 10px 14px;
font: inherit;
font-weight: 700;
cursor: pointer;
color: white;
background: linear-gradient(135deg, #0f766e, #115e59);
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
}
button.alt {
color: var(--ink);
background: rgba(255,255,255,0.9);
border: 1px solid var(--line);
box-shadow: none;
}
.viewer {
position: relative;
overflow: hidden;
min-height: 100vh;
}
.viewer-head {
position: absolute;
top: 16px;
left: 16px;
right: 16px;
z-index: 2;
display: flex;
justify-content: space-between;
gap: 12px;
pointer-events: none;
}
.chip {
padding: 10px 12px;
border-radius: 16px;
background: rgba(255,255,255,0.82);
border: 1px solid rgba(255,255,255,0.94);
color: var(--muted);
font-size: 13px;
font-weight: 700;
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
}
.viewer-actions {
position: absolute;
left: 16px;
top: 64px;
z-index: 2;
display: flex;
gap: 8px;
}
.mapper {
position: absolute;
top: 76px;
left: 50%;
transform: translateX(-50%);
width: min(94vw, 1320px);
max-height: min(56vh, 560px);
overflow: hidden;
z-index: 4;
border-radius: 20px;
background: rgba(234, 239, 247, 0.95);
border: 1px solid rgba(101, 119, 146, 0.22);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
display: flex;
flex-direction: column;
backdrop-filter: blur(6px);
}
.hidden-off {
display: none !important;
}
.mapper-head {
padding: 10px 14px;
border-bottom: 1px solid rgba(101,119,146,0.18);
font-size: 12px;
color: #51607a;
font-weight: 700;
line-height: 1.35;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: rgba(255,255,255,0.6);
}
.mapper-head strong {
display: block;
color: #17243b;
font-size: 20px;
margin-bottom: 2px;
}
.mapper-head .alt {
padding: 8px 10px;
font-size: 12px;
white-space: nowrap;
}
.org-chart {
margin: 0;
padding: 14px;
overflow: auto;
display: grid;
gap: 12px;
}
.org-top {
margin: 0 auto;
width: min(100%, 420px);
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(67, 84, 118, 0.25);
background: #fff;
}
.org-top-title {
background: #1e2f4d;
color: #fff;
text-align: center;
font-size: 34px;
font-weight: 800;
line-height: 1.1;
padding: 16px 12px;
letter-spacing: -0.03em;
}
.org-top-members {
padding: 10px;
display: grid;
gap: 6px;
background: rgba(255,255,255,0.95);
}
.org-teams {
display: grid;
grid-template-columns: repeat(7, minmax(160px, 1fr));
gap: 10px;
align-items: start;
}
.org-team {
border: 1px solid rgba(110, 126, 152, 0.25);
border-radius: 10px;
overflow: hidden;
background: rgba(255,255,255,0.95);
min-width: 0;
}
.org-team h4 {
margin: 0;
padding: 9px 10px;
font-size: 14px;
color: #21324e;
font-weight: 800;
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
background: rgba(240, 245, 252, 0.96);
}
.org-members {
padding: 7px;
display: grid;
gap: 6px;
}
.org-person {
border: 1px solid rgba(116, 133, 161, 0.25);
background: rgba(255,255,255,0.95);
border-radius: 8px;
padding: 6px 8px;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
min-width: 0;
}
.org-person.active {
border-color: rgba(15,118,110,0.6);
background: rgba(15,118,110,0.11);
}
.org-person.assigned {
border-color: rgba(37,99,235,0.5);
background: rgba(37,99,235,0.1);
}
.org-person strong {
display: block;
font-size: 13px;
line-height: 1.3;
color: #15233a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.org-person small {
display: block;
color: #5a6a86;
font-size: 11px;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 980px) {
.mapper {
top: 72px;
width: min(96vw, 920px);
max-height: 58vh;
}
.viewer-actions {
top: 64px;
left: 12px;
right: 12px;
flex-wrap: wrap;
}
.mapper-head strong {
font-size: 16px;
}
.org-top-title {
font-size: 24px;
}
.org-teams {
grid-template-columns: repeat(3, minmax(150px, 1fr));
}
}
canvas {
width: 100%;
height: 100%;
display: block;
cursor: grab;
}
canvas.dragging { cursor: grabbing; }
.tooltip {
position: absolute;
min-width: 170px;
padding: 12px 14px;
border-radius: 16px;
background: rgba(17,24,39,0.94);
color: white;
pointer-events: none;
opacity: 0;
transform: translate(12px, 12px);
transition: opacity 120ms ease;
z-index: 3;
}
.tooltip.visible { opacity: 1; }
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
</style>
</head>
<body>
<div class="page">
<div class="shell">
<main class="panel viewer">
<div class="viewer-head">
<div class="chip" id="scale-chip"></div>
<div class="chip" id="hover-chip">chair hover: none</div>
</div>
<div class="viewer-actions">
<button type="button" id="fit-btn">전체 맞춤</button>
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
</div>
<aside class="mapper hidden-off">
<div class="mapper-head">
<div id="mapper-status">
<strong>조직 현황</strong>
<span>선택 인원 없음</span>
</div>
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
</div>
<div class="org-chart" id="org-chart"></div>
</aside>
<canvas id="canvas"></canvas>
<div class="tooltip" id="tooltip"></div>
</main>
</div>
</div>
<script src="./center_chair_people_payload_6f.js"></script>
<script>
const DATA = window.CHAIR_MAP_DATA;
function decodeSegments(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return new Int32Array(bytes.buffer);
}
const bgTileRanges = DATA.bgTileRanges;
const bgSegValues = decodeSegments(DATA.bgSegsB64);
const chairSegValues = decodeSegments(DATA.chairSegsB64);
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
key, name, kind, start, count
}));
const meta = DATA.meta;
const world = meta.headerBounds;
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const tooltip = document.getElementById("tooltip");
const scaleChip = document.getElementById("scale-chip");
const hoverChip = document.getElementById("hover-chip");
const STORAGE_KEY = "ptc-chair-selection";
const PEOPLE_STORAGE_KEY = "ptc-chair-people";
const ASSIGN_STORAGE_KEY = "ptc-chair-assignments";
const ACTIVE_PERSON_STORAGE_KEY = "ptc-chair-active-person";
const clearAssignBtn = document.getElementById("clear-assign-btn");
const orgChartEl = document.getElementById("org-chart");
const mapperStatus = document.getElementById("mapper-status");
// Prevent stale auto-highlights from previous sessions.
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
localStorage.removeItem(ASSIGN_STORAGE_KEY);
const placed = new Set();
let people = JSON.parse(localStorage.getItem(PEOPLE_STORAGE_KEY) || "[]");
let chairAssignments = {};
let activePersonId = null;
const ORG_TEMPLATE = {
top: {
name: "총괄기획실",
count: 53,
members: [
{ name: "장종찬", dept: "총괄기획실", title: "기획실장" },
{ name: "김원식", dept: "총괄기획실", title: "전무이사" },
],
},
teams: [
{ name: "경영기획팀", count: 6, members: ["김우진", "임민정", "국혜린", "최선아", "김윤재", "이미영"] },
{ name: "인재성장팀", count: 5, members: ["조태희", "최근혜", "류원준", "주안기", "정성호"] },
{ name: "ERP 기획팀", count: 5, members: ["류호성", "문형식", "최요제", "황대일", "이채봉"] },
{ name: "디자인기획팀", count: 17, members: ["신혜영", "정은혜", "김태식", "최예은", "채선영", "최영환", "윤봄이", "이예진", "허유나", "마희연", "김수현", "박지영", "권순호", "정두휘", "김정석", "정지윤", "양숙영"] },
{ name: "기술기획팀", count: 11, members: ["김원기", "홍아름", "이경민", "김혜인", "황동환", "최찬호", "이태훈", "김신지", "조찬영", "김용연", "한치영"] },
{ name: "협업증진팀", count: 3, members: ["성형일", "박주한", "한승민"] },
{ name: "솔루션통합팀", count: 4, members: ["권혁진", "염승호", "윤준수", "김지영"] },
],
};
const chairGeometry = chairs.map((chair) => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const path = new Path2D();
const hitSegments = new Float32Array(chair.count * 4);
let segCursor = 0;
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
const offset = i * 4;
const x1 = chairSegValues[offset] / 10;
const y1 = chairSegValues[offset + 1] / 10;
const x2 = chairSegValues[offset + 2] / 10;
const y2 = chairSegValues[offset + 3] / 10;
path.moveTo(x1, y1);
path.lineTo(x2, y2);
hitSegments[segCursor] = x1;
hitSegments[segCursor + 1] = y1;
hitSegments[segCursor + 2] = x2;
hitSegments[segCursor + 3] = y2;
segCursor += 4;
minX = Math.min(minX, x1, x2);
minY = Math.min(minY, y1, y2);
maxX = Math.max(maxX, x1, x2);
maxY = Math.max(maxY, y1, y2);
}
return {
...chair,
minX,
minY,
maxX,
maxY,
area: Math.max(1, (maxX - minX) * (maxY - minY)),
path,
hitSegments,
};
});
function renumberChairKeys(chairItems) {
if (!chairItems.length) return;
const heights = chairItems
.map((chair) => Math.max(1, chair.maxY - chair.minY))
.sort((a, b) => a - b);
const medianHeight = heights[Math.floor(heights.length / 2)] || 1;
const rowTolerance = Math.max(40, medianHeight * 0.9);
const sorted = [...chairItems].sort((a, b) => {
const ay = (a.minY + a.maxY) * 0.5;
const by = (b.minY + b.maxY) * 0.5;
if (Math.abs(by - ay) > rowTolerance) return by - ay; // top -> bottom
const ax = (a.minX + a.maxX) * 0.5;
const bx = (b.minX + b.maxX) * 0.5;
return ax - bx; // left -> right
});
sorted.forEach((chair, index) => {
chair.key = String(index + 1);
chair.seatNo = index + 1;
});
}
renumberChairKeys(chairGeometry);
const PICK_GRID_SIZE = 1800;
const chairPickGrid = new Map();
function pickGridKey(gx, gy) {
return `${gx},${gy}`;
}
chairGeometry.forEach((chair, index) => {
const minGX = Math.floor(chair.minX / PICK_GRID_SIZE);
const maxGX = Math.floor(chair.maxX / PICK_GRID_SIZE);
const minGY = Math.floor(chair.minY / PICK_GRID_SIZE);
const maxGY = Math.floor(chair.maxY / PICK_GRID_SIZE);
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const key = pickGridKey(gx, gy);
if (!chairPickGrid.has(key)) chairPickGrid.set(key, []);
chairPickGrid.get(key).push(index);
}
}
});
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
let pixelRatio = window.devicePixelRatio || 1;
let pointer = { x: 0, y: 0 };
let dragging = false;
let dragStart = null;
let hovered = null;
let rafPending = false;
function normalizePeople(raw) {
return raw
.map((person, index) => {
if (!person || !person.name) return null;
return {
id: person.id || `person-${index + 1}`,
name: String(person.name).trim(),
dept: String(person.dept || "").trim(),
title: String(person.title || "").trim(),
};
})
.filter(Boolean);
}
function createTemplatePeople() {
const generated = [];
let seq = 1;
ORG_TEMPLATE.top.members.forEach((member) => {
generated.push({
id: `org-${seq++}`,
name: member.name,
dept: member.dept,
title: member.title,
});
});
ORG_TEMPLATE.teams.forEach((team) => {
team.members.forEach((name) => {
generated.push({
id: `org-${seq++}`,
name,
dept: team.name,
title: "선임",
});
});
});
return generated;
}
people = normalizePeople(people);
const templateReady = people.some((person) => person.name === "장종찬" && person.dept === "총괄기획실");
if (!templateReady) {
people = createTemplatePeople();
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
const chairKeySet = new Set(chairGeometry.map((chair) => chair.key));
chairAssignments = Object.fromEntries(
Object.entries(chairAssignments).filter(([chairKey, personId]) => (
chairKeySet.has(chairKey) && people.some((person) => person.id === personId)
))
);
if (activePersonId && !people.some((person) => person.id === activePersonId)) activePersonId = null;
function persistPeople() {
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
function persistAssignments() {
localStorage.setItem(ASSIGN_STORAGE_KEY, JSON.stringify(chairAssignments));
}
function persistActivePerson() {
if (!activePersonId) localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
else localStorage.setItem(ACTIVE_PERSON_STORAGE_KEY, activePersonId);
}
function assignmentCount() {
return Object.keys(chairAssignments).length;
}
function getPersonById(id) {
return people.find((person) => person.id === id) || null;
}
function getChairByPerson(personId) {
for (const [chairKey, assignedPersonId] of Object.entries(chairAssignments)) {
if (assignedPersonId === personId) return chairKey;
}
return null;
}
function renderPeopleList() {
const activePerson = getPersonById(activePersonId);
const countText = `${assignmentCount()} / ${people.length} 매칭`;
mapperStatus.innerHTML = `<strong>조직 현황</strong><span>${activePerson ? `${activePerson.name} 선택됨` : "선택 인원 없음"} · ${countText}</span>`;
const findPerson = (dept, name) => people.find((person) => person.dept === dept && person.name === name) || null;
const personCard = (person, roleText) => {
if (!person) return "";
const chairKey = getChairByPerson(person.id);
const assignedClass = chairKey ? " assigned" : "";
const activeClass = person.id === activePersonId ? " active" : "";
return `
<article class="org-person${assignedClass}${activeClass}" data-person-id="${person.id}">
<strong>${person.name}</strong>
<small>${person.title || roleText || "-"}</small>
<small>${chairKey ? `좌석 ${chairKey}` : "좌석 미지정"}</small>
</article>
`;
};
const topHtml = ORG_TEMPLATE.top.members
.map((member) => personCard(findPerson(member.dept, member.name), member.title))
.join("");
const teamsHtml = ORG_TEMPLATE.teams.map((team) => {
const membersHtml = team.members
.map((name) => personCard(findPerson(team.name, name), "선임"))
.join("");
return `
<section class="org-team">
<h4>${team.name} (${team.count})</h4>
<div class="org-members">${membersHtml}</div>
</section>
`;
}).join("");
orgChartEl.innerHTML = `
<section class="org-top">
<div class="org-top-title">${ORG_TEMPLATE.top.name} (${ORG_TEMPLATE.top.count})</div>
<div class="org-top-members">${topHtml}</div>
</section>
<section class="org-teams">${teamsHtml}</section>
`;
}
function worldToScreen(x, y) {
return {
x: x * camera.scale + camera.offsetX,
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
};
}
function screenToWorld(x, y) {
return {
x: (x - camera.offsetX) / camera.scale,
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
};
}
function resize() {
pixelRatio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * pixelRatio);
canvas.height = Math.round(rect.height * pixelRatio);
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
fit();
}
function fit() {
const rect = canvas.getBoundingClientRect();
const width = world.maxX - world.minX;
const height = world.maxY - world.minY;
const pad = 36;
const scaleX = (rect.width - pad * 2) / width;
const scaleY = (rect.height - pad * 2) / height;
camera.scale = Math.min(scaleX, scaleY);
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
requestDraw();
}
function drawGrid(width, height) {
ctx.save();
ctx.strokeStyle = "rgba(21,35,48,0.05)";
ctx.lineWidth = 1;
for (let x = 120; x < width; x += 120) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 120; y < height; y += 120) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
ctx.restore();
}
function pickChair(screenX, screenY) {
const threshold = 12;
const pointerWorld = screenToWorld(screenX, screenY);
const thresholdWorld = threshold / camera.scale;
const thresholdWorldSq = thresholdWorld * thresholdWorld;
const minGX = Math.floor((pointerWorld.x - thresholdWorld) / PICK_GRID_SIZE);
const maxGX = Math.floor((pointerWorld.x + thresholdWorld) / PICK_GRID_SIZE);
const minGY = Math.floor((pointerWorld.y - thresholdWorld) / PICK_GRID_SIZE);
const maxGY = Math.floor((pointerWorld.y + thresholdWorld) / PICK_GRID_SIZE);
const candidateIndexes = [];
const seen = new Set();
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const candidates = chairPickGrid.get(pickGridKey(gx, gy));
if (!candidates) continue;
for (const index of candidates) {
if (seen.has(index)) continue;
seen.add(index);
candidateIndexes.push(index);
}
}
}
let best = null;
for (const index of candidateIndexes) {
const chair = chairGeometry[index];
if (
pointerWorld.x < chair.minX - thresholdWorld ||
pointerWorld.x > chair.maxX + thresholdWorld ||
pointerWorld.y < chair.minY - thresholdWorld ||
pointerWorld.y > chair.maxY + thresholdWorld
) continue;
let distSq = Infinity;
for (let i = 0; i < chair.hitSegments.length; i += 4) {
const x1 = chair.hitSegments[i];
const y1 = chair.hitSegments[i + 1];
const x2 = chair.hitSegments[i + 2];
const y2 = chair.hitSegments[i + 3];
const dx = x2 - x1;
const dy = y2 - y1;
const len2 = dx * dx + dy * dy;
let segDistSq;
if (len2 === 0) {
const px = pointerWorld.x - x1;
const py = pointerWorld.y - y1;
segDistSq = px * px + py * py;
} else {
let t = ((pointerWorld.x - x1) * dx + (pointerWorld.y - y1) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const lx = x1 + t * dx;
const ly = y1 + t * dy;
const px = pointerWorld.x - lx;
const py = pointerWorld.y - ly;
segDistSq = px * px + py * py;
}
if (segDistSq < distSq) distSq = segDistSq;
if (distSq <= thresholdWorldSq * 0.3) break;
}
if (distSq > thresholdWorldSq) continue;
const dist = Math.sqrt(distSq) * camera.scale;
if (!best) {
best = { chair, dist };
continue;
}
const distGap = dist - best.dist;
if (distGap < -0.75) {
best = { chair, dist };
continue;
}
if (Math.abs(distGap) <= 2) {
const areaGap = chair.area - best.chair.area;
if (areaGap < -1) {
best = { chair, dist };
continue;
}
if (
Math.abs(areaGap) <= 1 &&
chair.kind === "block" &&
best.chair.kind !== "block"
) {
best = { chair, dist };
}
}
}
return best ? best.chair : null;
}
function renderTooltip() {
if (!hovered) {
tooltip.classList.remove("visible");
hoverChip.textContent = "chair hover: none";
return;
}
hoverChip.textContent = `chair hover: ${hovered.name}`;
tooltip.innerHTML = `
<strong>${hovered.name}</strong>
<div>chair key: ${hovered.key}</div>
<div>${placed.has(hovered.key) ? "선택됨" : "클릭하면 선택"}</div>
<div>${chairAssignments[hovered.key] ? `배치: ${(getPersonById(chairAssignments[hovered.key]) || { name: "알수없음" }).name}` : "배치 인원 없음"}</div>
`;
tooltip.style.left = `${pointer.x + 14}px`;
tooltip.style.top = `${pointer.y + 14}px`;
tooltip.classList.add("visible");
}
function requestDraw() {
if (rafPending) return;
rafPending = true;
window.requestAnimationFrame(() => {
rafPending = false;
draw();
});
}
function applyWorldTransform() {
ctx.setTransform(
pixelRatio * camera.scale,
0,
0,
-pixelRatio * camera.scale,
pixelRatio * camera.offsetX,
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
);
}
function draw() {
const rect = canvas.getBoundingClientRect();
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.clearRect(0, 0, rect.width, rect.height);
drawGrid(rect.width, rect.height);
const viewA = screenToWorld(0, rect.height);
const viewB = screenToWorld(rect.width, 0);
const viewMinX = Math.min(viewA.x, viewB.x);
const viewMaxX = Math.max(viewA.x, viewB.x);
const viewMinY = Math.min(viewA.y, viewB.y);
const viewMaxY = Math.max(viewA.y, viewB.y);
ctx.save();
applyWorldTransform();
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
ctx.lineWidth = 1 / camera.scale;
const tileSize = meta.backgroundTileSize;
const tileMinX = Math.floor(viewMinX / tileSize);
const tileMaxX = Math.floor(viewMaxX / tileSize);
const tileMinY = Math.floor(viewMinY / tileSize);
const tileMaxY = Math.floor(viewMaxY / tileSize);
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
const range = bgTileRanges[`${tx},${ty}`];
if (!range) continue;
const start = range[0];
const count = range[1];
for (let i = start; i < start + count; i += 1) {
const offset = i * 4;
const x1 = bgSegValues[offset] / 10;
const y1 = bgSegValues[offset + 1] / 10;
const x2 = bgSegValues[offset + 2] / 10;
const y2 = bgSegValues[offset + 3] / 10;
if (
Math.max(x1, x2) < viewMinX ||
Math.min(x1, x2) > viewMaxX ||
Math.max(y1, y2) < viewMinY ||
Math.min(y1, y2) > viewMaxY
) continue;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
}
}
ctx.restore();
hovered = dragging ? null : pickChair(pointer.x, pointer.y);
ctx.save();
applyWorldTransform();
ctx.lineWidth = 1.45 / camera.scale;
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (const chair of chairGeometry) {
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
const active = hovered && hovered.key === chair.key;
const selected = placed.has(chair.key);
const assignedPersonId = chairAssignments[chair.key];
const activePersonChair = activePersonId && assignedPersonId === activePersonId;
const assigned = Boolean(assignedPersonId);
const baseWidth = chair.kind === "block" ? 1.45 : 1.35;
ctx.strokeStyle = activePersonChair
? "rgba(234, 179, 8, 1)"
: assigned
? "rgba(37, 99, 235, 0.98)"
: selected
? "rgba(220, 38, 38, 0.98)"
: active
? "rgba(15, 118, 110, 0.98)"
: chair.kind === "group"
? "rgba(16, 134, 149, 0.74)"
: "rgba(21, 149, 142, 0.8)";
ctx.lineWidth = (activePersonChair ? 2.8 : assigned ? 2.4 : selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;
ctx.stroke(chair.path);
}
ctx.restore();
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
renderTooltip();
}
function persistPlaced() {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));
}
canvas.addEventListener("pointerdown", (event) => {
dragging = true;
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
canvas.classList.add("dragging");
});
window.addEventListener("pointerup", (event) => {
if (dragging && dragStart) {
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
if (move < 4) {
const rect = canvas.getBoundingClientRect();
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
if (picked) {
if (placed.has(picked.key)) placed.delete(picked.key);
else placed.add(picked.key);
persistPlaced();
if (activePersonId) {
const currentChair = getChairByPerson(activePersonId);
if (chairAssignments[picked.key] === activePersonId) {
delete chairAssignments[picked.key];
} else {
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
chairAssignments[picked.key] = activePersonId;
}
persistAssignments();
renderPeopleList();
}
}
}
}
dragging = false;
dragStart = null;
canvas.classList.remove("dragging");
requestDraw();
});
window.addEventListener("pointermove", (event) => {
const rect = canvas.getBoundingClientRect();
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
if (dragging && dragStart) {
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
}
requestDraw();
});
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = event.clientX - rect.left;
const my = event.clientY - rect.top;
const before = screenToWorld(mx, my);
const factor = event.deltaY < 0 ? 1.08 : 0.92;
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
const after = worldToScreen(before.x, before.y);
camera.offsetX += mx - after.x;
camera.offsetY += my - after.y;
requestDraw();
}, { passive: false });
document.getElementById("fit-btn").addEventListener("click", fit);
document.getElementById("clear-btn").addEventListener("click", () => {
placed.clear();
persistPlaced();
requestDraw();
});
clearAssignBtn.addEventListener("click", () => {
chairAssignments = {};
persistAssignments();
renderPeopleList();
requestDraw();
});
orgChartEl.addEventListener("click", (event) => {
const item = event.target.closest(".org-person[data-person-id]");
if (!item) return;
const personId = item.getAttribute("data-person-id");
activePersonId = personId === activePersonId ? null : personId;
persistActivePerson();
renderPeopleList();
requestDraw();
});
window.addEventListener("resize", resize);
renderPeopleList();
resize();
</script>
</body>
</html>

View File

@@ -0,0 +1,932 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>center chair people map 7f</title>
<style>
:root {
--ink: #152330;
--muted: #627286;
--paper: rgba(255,255,255,0.86);
--line: rgba(21,35,48,0.1);
--accent: #0f766e;
--bg: #edf2f6;
}
* { box-sizing: border-box; }
body {
margin: 0;
font-family: "IBM Plex Sans KR", "Pretendard", sans-serif;
color: var(--ink);
background:
radial-gradient(circle at top left, rgba(15,118,110,0.11), transparent 22%),
linear-gradient(180deg, #f5f8fb 0%, #e8eef3 100%);
}
.page {
min-height: 100vh;
padding: 0;
}
.shell {
min-height: 100vh;
}
.panel {
border-radius: 0;
border: none;
background: transparent;
backdrop-filter: none;
box-shadow: none;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
button {
border: none;
border-radius: 999px;
padding: 10px 14px;
font: inherit;
font-weight: 700;
cursor: pointer;
color: white;
background: linear-gradient(135deg, #0f766e, #115e59);
box-shadow: 0 10px 22px rgba(15,118,110,0.18);
}
button.alt {
color: var(--ink);
background: rgba(255,255,255,0.9);
border: 1px solid var(--line);
box-shadow: none;
}
.viewer {
position: relative;
overflow: hidden;
min-height: 100vh;
}
.viewer-head {
position: absolute;
top: 16px;
left: 16px;
right: 16px;
z-index: 2;
display: flex;
justify-content: space-between;
gap: 12px;
pointer-events: none;
}
.chip {
padding: 10px 12px;
border-radius: 16px;
background: rgba(255,255,255,0.82);
border: 1px solid rgba(255,255,255,0.94);
color: var(--muted);
font-size: 13px;
font-weight: 700;
box-shadow: 0 8px 24px rgba(21,35,48,0.08);
}
.viewer-actions {
position: absolute;
left: 16px;
top: 64px;
z-index: 2;
display: flex;
gap: 8px;
}
.mapper {
position: absolute;
top: 76px;
left: 50%;
transform: translateX(-50%);
width: min(94vw, 1320px);
max-height: min(56vh, 560px);
overflow: hidden;
z-index: 4;
border-radius: 20px;
background: rgba(234, 239, 247, 0.95);
border: 1px solid rgba(101, 119, 146, 0.22);
box-shadow: 0 18px 36px rgba(15, 23, 42, 0.2);
display: flex;
flex-direction: column;
backdrop-filter: blur(6px);
}
.hidden-off {
display: none !important;
}
.mapper-head {
padding: 10px 14px;
border-bottom: 1px solid rgba(101,119,146,0.18);
font-size: 12px;
color: #51607a;
font-weight: 700;
line-height: 1.35;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: rgba(255,255,255,0.6);
}
.mapper-head strong {
display: block;
color: #17243b;
font-size: 20px;
margin-bottom: 2px;
}
.mapper-head .alt {
padding: 8px 10px;
font-size: 12px;
white-space: nowrap;
}
.org-chart {
margin: 0;
padding: 14px;
overflow: auto;
display: grid;
gap: 12px;
}
.org-top {
margin: 0 auto;
width: min(100%, 420px);
border-radius: 14px;
overflow: hidden;
border: 1px solid rgba(67, 84, 118, 0.25);
background: #fff;
}
.org-top-title {
background: #1e2f4d;
color: #fff;
text-align: center;
font-size: 34px;
font-weight: 800;
line-height: 1.1;
padding: 16px 12px;
letter-spacing: -0.03em;
}
.org-top-members {
padding: 10px;
display: grid;
gap: 6px;
background: rgba(255,255,255,0.95);
}
.org-teams {
display: grid;
grid-template-columns: repeat(7, minmax(160px, 1fr));
gap: 10px;
align-items: start;
}
.org-team {
border: 1px solid rgba(110, 126, 152, 0.25);
border-radius: 10px;
overflow: hidden;
background: rgba(255,255,255,0.95);
min-width: 0;
}
.org-team h4 {
margin: 0;
padding: 9px 10px;
font-size: 14px;
color: #21324e;
font-weight: 800;
border-bottom: 1px solid rgba(110, 126, 152, 0.2);
background: rgba(240, 245, 252, 0.96);
}
.org-members {
padding: 7px;
display: grid;
gap: 6px;
}
.org-person {
border: 1px solid rgba(116, 133, 161, 0.25);
background: rgba(255,255,255,0.95);
border-radius: 8px;
padding: 6px 8px;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease;
min-width: 0;
}
.org-person.active {
border-color: rgba(15,118,110,0.6);
background: rgba(15,118,110,0.11);
}
.org-person.assigned {
border-color: rgba(37,99,235,0.5);
background: rgba(37,99,235,0.1);
}
.org-person strong {
display: block;
font-size: 13px;
line-height: 1.3;
color: #15233a;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.org-person small {
display: block;
color: #5a6a86;
font-size: 11px;
line-height: 1.25;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 980px) {
.mapper {
top: 72px;
width: min(96vw, 920px);
max-height: 58vh;
}
.viewer-actions {
top: 64px;
left: 12px;
right: 12px;
flex-wrap: wrap;
}
.mapper-head strong {
font-size: 16px;
}
.org-top-title {
font-size: 24px;
}
.org-teams {
grid-template-columns: repeat(3, minmax(150px, 1fr));
}
}
canvas {
width: 100%;
height: 100%;
display: block;
cursor: grab;
}
canvas.dragging { cursor: grabbing; }
.tooltip {
position: absolute;
min-width: 170px;
padding: 12px 14px;
border-radius: 16px;
background: rgba(17,24,39,0.94);
color: white;
pointer-events: none;
opacity: 0;
transform: translate(12px, 12px);
transition: opacity 120ms ease;
z-index: 3;
}
.tooltip.visible { opacity: 1; }
.tooltip strong { display: block; margin-bottom: 6px; font-size: 14px; }
.tooltip div { font-size: 12px; line-height: 1.45; color: rgba(255,255,255,0.82); }
</style>
</head>
<body>
<div class="page">
<div class="shell">
<main class="panel viewer">
<div class="viewer-head">
<div class="chip" id="scale-chip"></div>
<div class="chip" id="hover-chip">chair hover: none</div>
</div>
<div class="viewer-actions">
<button type="button" id="fit-btn">전체 맞춤</button>
<button type="button" class="alt" id="clear-btn">선택 지우기</button>
</div>
<aside class="mapper hidden-off">
<div class="mapper-head">
<div id="mapper-status">
<strong>조직 현황</strong>
<span>선택 인원 없음</span>
</div>
<button type="button" class="alt" id="clear-assign-btn">매칭 초기화</button>
</div>
<div class="org-chart" id="org-chart"></div>
</aside>
<canvas id="canvas"></canvas>
<div class="tooltip" id="tooltip"></div>
</main>
</div>
</div>
<script src="./center_chair_people_payload_7f.js"></script>
<script>
const DATA = window.CHAIR_MAP_DATA;
function decodeSegments(base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
return new Int32Array(bytes.buffer);
}
const bgTileRanges = DATA.bgTileRanges;
const bgSegValues = decodeSegments(DATA.bgSegsB64);
const chairSegValues = decodeSegments(DATA.chairSegsB64);
const chairs = DATA.chairs.map(([key, name, kind, start, count]) => ({
key, name, kind, start, count
}));
const meta = DATA.meta;
const world = meta.headerBounds;
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");
const tooltip = document.getElementById("tooltip");
const scaleChip = document.getElementById("scale-chip");
const hoverChip = document.getElementById("hover-chip");
const STORAGE_KEY = "ptc-chair-selection";
const PEOPLE_STORAGE_KEY = "ptc-chair-people";
const ASSIGN_STORAGE_KEY = "ptc-chair-assignments";
const ACTIVE_PERSON_STORAGE_KEY = "ptc-chair-active-person";
const clearAssignBtn = document.getElementById("clear-assign-btn");
const orgChartEl = document.getElementById("org-chart");
const mapperStatus = document.getElementById("mapper-status");
// Prevent stale auto-highlights from previous sessions.
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
localStorage.removeItem(ASSIGN_STORAGE_KEY);
const placed = new Set();
let people = JSON.parse(localStorage.getItem(PEOPLE_STORAGE_KEY) || "[]");
let chairAssignments = {};
let activePersonId = null;
const ORG_TEMPLATE = {
top: {
name: "총괄기획실",
count: 53,
members: [
{ name: "장종찬", dept: "총괄기획실", title: "기획실장" },
{ name: "김원식", dept: "총괄기획실", title: "전무이사" },
],
},
teams: [
{ name: "경영기획팀", count: 6, members: ["김우진", "임민정", "국혜린", "최선아", "김윤재", "이미영"] },
{ name: "인재성장팀", count: 5, members: ["조태희", "최근혜", "류원준", "주안기", "정성호"] },
{ name: "ERP 기획팀", count: 5, members: ["류호성", "문형식", "최요제", "황대일", "이채봉"] },
{ name: "디자인기획팀", count: 17, members: ["신혜영", "정은혜", "김태식", "최예은", "채선영", "최영환", "윤봄이", "이예진", "허유나", "마희연", "김수현", "박지영", "권순호", "정두휘", "김정석", "정지윤", "양숙영"] },
{ name: "기술기획팀", count: 11, members: ["김원기", "홍아름", "이경민", "김혜인", "황동환", "최찬호", "이태훈", "김신지", "조찬영", "김용연", "한치영"] },
{ name: "협업증진팀", count: 3, members: ["성형일", "박주한", "한승민"] },
{ name: "솔루션통합팀", count: 4, members: ["권혁진", "염승호", "윤준수", "김지영"] },
],
};
const chairGeometry = chairs.map((chair) => {
let minX = Infinity;
let minY = Infinity;
let maxX = -Infinity;
let maxY = -Infinity;
const path = new Path2D();
const hitSegments = new Float32Array(chair.count * 4);
let segCursor = 0;
for (let i = chair.start; i < chair.start + chair.count; i += 1) {
const offset = i * 4;
const x1 = chairSegValues[offset] / 10;
const y1 = chairSegValues[offset + 1] / 10;
const x2 = chairSegValues[offset + 2] / 10;
const y2 = chairSegValues[offset + 3] / 10;
path.moveTo(x1, y1);
path.lineTo(x2, y2);
hitSegments[segCursor] = x1;
hitSegments[segCursor + 1] = y1;
hitSegments[segCursor + 2] = x2;
hitSegments[segCursor + 3] = y2;
segCursor += 4;
minX = Math.min(minX, x1, x2);
minY = Math.min(minY, y1, y2);
maxX = Math.max(maxX, x1, x2);
maxY = Math.max(maxY, y1, y2);
}
return {
...chair,
minX,
minY,
maxX,
maxY,
area: Math.max(1, (maxX - minX) * (maxY - minY)),
path,
hitSegments,
};
});
function renumberChairKeys(chairItems) {
if (!chairItems.length) return;
const heights = chairItems
.map((chair) => Math.max(1, chair.maxY - chair.minY))
.sort((a, b) => a - b);
const medianHeight = heights[Math.floor(heights.length / 2)] || 1;
const rowTolerance = Math.max(40, medianHeight * 0.9);
const sorted = [...chairItems].sort((a, b) => {
const ay = (a.minY + a.maxY) * 0.5;
const by = (b.minY + b.maxY) * 0.5;
if (Math.abs(by - ay) > rowTolerance) return by - ay; // top -> bottom
const ax = (a.minX + a.maxX) * 0.5;
const bx = (b.minX + b.maxX) * 0.5;
return ax - bx; // left -> right
});
sorted.forEach((chair, index) => {
chair.key = String(index + 1);
chair.seatNo = index + 1;
});
}
renumberChairKeys(chairGeometry);
const PICK_GRID_SIZE = 1800;
const chairPickGrid = new Map();
function pickGridKey(gx, gy) {
return `${gx},${gy}`;
}
chairGeometry.forEach((chair, index) => {
const minGX = Math.floor(chair.minX / PICK_GRID_SIZE);
const maxGX = Math.floor(chair.maxX / PICK_GRID_SIZE);
const minGY = Math.floor(chair.minY / PICK_GRID_SIZE);
const maxGY = Math.floor(chair.maxY / PICK_GRID_SIZE);
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const key = pickGridKey(gx, gy);
if (!chairPickGrid.has(key)) chairPickGrid.set(key, []);
chairPickGrid.get(key).push(index);
}
}
});
const camera = { scale: 1, offsetX: 0, offsetY: 0 };
let pixelRatio = window.devicePixelRatio || 1;
let pointer = { x: 0, y: 0 };
let dragging = false;
let dragStart = null;
let hovered = null;
let rafPending = false;
function normalizePeople(raw) {
return raw
.map((person, index) => {
if (!person || !person.name) return null;
return {
id: person.id || `person-${index + 1}`,
name: String(person.name).trim(),
dept: String(person.dept || "").trim(),
title: String(person.title || "").trim(),
};
})
.filter(Boolean);
}
function createTemplatePeople() {
const generated = [];
let seq = 1;
ORG_TEMPLATE.top.members.forEach((member) => {
generated.push({
id: `org-${seq++}`,
name: member.name,
dept: member.dept,
title: member.title,
});
});
ORG_TEMPLATE.teams.forEach((team) => {
team.members.forEach((name) => {
generated.push({
id: `org-${seq++}`,
name,
dept: team.name,
title: "선임",
});
});
});
return generated;
}
people = normalizePeople(people);
const templateReady = people.some((person) => person.name === "장종찬" && person.dept === "총괄기획실");
if (!templateReady) {
people = createTemplatePeople();
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
const chairKeySet = new Set(chairGeometry.map((chair) => chair.key));
chairAssignments = Object.fromEntries(
Object.entries(chairAssignments).filter(([chairKey, personId]) => (
chairKeySet.has(chairKey) && people.some((person) => person.id === personId)
))
);
if (activePersonId && !people.some((person) => person.id === activePersonId)) activePersonId = null;
function persistPeople() {
localStorage.setItem(PEOPLE_STORAGE_KEY, JSON.stringify(people));
}
function persistAssignments() {
localStorage.setItem(ASSIGN_STORAGE_KEY, JSON.stringify(chairAssignments));
}
function persistActivePerson() {
if (!activePersonId) localStorage.removeItem(ACTIVE_PERSON_STORAGE_KEY);
else localStorage.setItem(ACTIVE_PERSON_STORAGE_KEY, activePersonId);
}
function assignmentCount() {
return Object.keys(chairAssignments).length;
}
function getPersonById(id) {
return people.find((person) => person.id === id) || null;
}
function getChairByPerson(personId) {
for (const [chairKey, assignedPersonId] of Object.entries(chairAssignments)) {
if (assignedPersonId === personId) return chairKey;
}
return null;
}
function renderPeopleList() {
const activePerson = getPersonById(activePersonId);
const countText = `${assignmentCount()} / ${people.length} 매칭`;
mapperStatus.innerHTML = `<strong>조직 현황</strong><span>${activePerson ? `${activePerson.name} 선택됨` : "선택 인원 없음"} · ${countText}</span>`;
const findPerson = (dept, name) => people.find((person) => person.dept === dept && person.name === name) || null;
const personCard = (person, roleText) => {
if (!person) return "";
const chairKey = getChairByPerson(person.id);
const assignedClass = chairKey ? " assigned" : "";
const activeClass = person.id === activePersonId ? " active" : "";
return `
<article class="org-person${assignedClass}${activeClass}" data-person-id="${person.id}">
<strong>${person.name}</strong>
<small>${person.title || roleText || "-"}</small>
<small>${chairKey ? `좌석 ${chairKey}` : "좌석 미지정"}</small>
</article>
`;
};
const topHtml = ORG_TEMPLATE.top.members
.map((member) => personCard(findPerson(member.dept, member.name), member.title))
.join("");
const teamsHtml = ORG_TEMPLATE.teams.map((team) => {
const membersHtml = team.members
.map((name) => personCard(findPerson(team.name, name), "선임"))
.join("");
return `
<section class="org-team">
<h4>${team.name} (${team.count})</h4>
<div class="org-members">${membersHtml}</div>
</section>
`;
}).join("");
orgChartEl.innerHTML = `
<section class="org-top">
<div class="org-top-title">${ORG_TEMPLATE.top.name} (${ORG_TEMPLATE.top.count})</div>
<div class="org-top-members">${topHtml}</div>
</section>
<section class="org-teams">${teamsHtml}</section>
`;
}
function worldToScreen(x, y) {
return {
x: x * camera.scale + camera.offsetX,
y: (world.maxY - y + world.minY) * camera.scale + camera.offsetY,
};
}
function screenToWorld(x, y) {
return {
x: (x - camera.offsetX) / camera.scale,
y: world.maxY + world.minY - (y - camera.offsetY) / camera.scale,
};
}
function resize() {
pixelRatio = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = Math.round(rect.width * pixelRatio);
canvas.height = Math.round(rect.height * pixelRatio);
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
fit();
}
function fit() {
const rect = canvas.getBoundingClientRect();
const width = world.maxX - world.minX;
const height = world.maxY - world.minY;
const pad = 36;
const scaleX = (rect.width - pad * 2) / width;
const scaleY = (rect.height - pad * 2) / height;
camera.scale = Math.min(scaleX, scaleY);
camera.offsetX = pad - world.minX * camera.scale + (rect.width - pad * 2 - width * camera.scale) / 2;
camera.offsetY = pad - world.minY * camera.scale + (rect.height - pad * 2 - height * camera.scale) / 2;
requestDraw();
}
function drawGrid(width, height) {
ctx.save();
ctx.strokeStyle = "rgba(21,35,48,0.05)";
ctx.lineWidth = 1;
for (let x = 120; x < width; x += 120) {
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, height);
ctx.stroke();
}
for (let y = 120; y < height; y += 120) {
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(width, y);
ctx.stroke();
}
ctx.restore();
}
function pickChair(screenX, screenY) {
const threshold = 12;
const pointerWorld = screenToWorld(screenX, screenY);
const thresholdWorld = threshold / camera.scale;
const thresholdWorldSq = thresholdWorld * thresholdWorld;
const minGX = Math.floor((pointerWorld.x - thresholdWorld) / PICK_GRID_SIZE);
const maxGX = Math.floor((pointerWorld.x + thresholdWorld) / PICK_GRID_SIZE);
const minGY = Math.floor((pointerWorld.y - thresholdWorld) / PICK_GRID_SIZE);
const maxGY = Math.floor((pointerWorld.y + thresholdWorld) / PICK_GRID_SIZE);
const candidateIndexes = [];
const seen = new Set();
for (let gx = minGX; gx <= maxGX; gx += 1) {
for (let gy = minGY; gy <= maxGY; gy += 1) {
const candidates = chairPickGrid.get(pickGridKey(gx, gy));
if (!candidates) continue;
for (const index of candidates) {
if (seen.has(index)) continue;
seen.add(index);
candidateIndexes.push(index);
}
}
}
let best = null;
for (const index of candidateIndexes) {
const chair = chairGeometry[index];
if (
pointerWorld.x < chair.minX - thresholdWorld ||
pointerWorld.x > chair.maxX + thresholdWorld ||
pointerWorld.y < chair.minY - thresholdWorld ||
pointerWorld.y > chair.maxY + thresholdWorld
) continue;
let distSq = Infinity;
for (let i = 0; i < chair.hitSegments.length; i += 4) {
const x1 = chair.hitSegments[i];
const y1 = chair.hitSegments[i + 1];
const x2 = chair.hitSegments[i + 2];
const y2 = chair.hitSegments[i + 3];
const dx = x2 - x1;
const dy = y2 - y1;
const len2 = dx * dx + dy * dy;
let segDistSq;
if (len2 === 0) {
const px = pointerWorld.x - x1;
const py = pointerWorld.y - y1;
segDistSq = px * px + py * py;
} else {
let t = ((pointerWorld.x - x1) * dx + (pointerWorld.y - y1) * dy) / len2;
t = Math.max(0, Math.min(1, t));
const lx = x1 + t * dx;
const ly = y1 + t * dy;
const px = pointerWorld.x - lx;
const py = pointerWorld.y - ly;
segDistSq = px * px + py * py;
}
if (segDistSq < distSq) distSq = segDistSq;
if (distSq <= thresholdWorldSq * 0.3) break;
}
if (distSq > thresholdWorldSq) continue;
const dist = Math.sqrt(distSq) * camera.scale;
if (!best) {
best = { chair, dist };
continue;
}
const distGap = dist - best.dist;
if (distGap < -0.75) {
best = { chair, dist };
continue;
}
if (Math.abs(distGap) <= 2) {
const areaGap = chair.area - best.chair.area;
if (areaGap < -1) {
best = { chair, dist };
continue;
}
if (
Math.abs(areaGap) <= 1 &&
chair.kind === "block" &&
best.chair.kind !== "block"
) {
best = { chair, dist };
}
}
}
return best ? best.chair : null;
}
function renderTooltip() {
if (!hovered) {
tooltip.classList.remove("visible");
hoverChip.textContent = "chair hover: none";
return;
}
hoverChip.textContent = `chair hover: ${hovered.name}`;
tooltip.innerHTML = `
<strong>${hovered.name}</strong>
<div>chair key: ${hovered.key}</div>
<div>${placed.has(hovered.key) ? "선택됨" : "클릭하면 선택"}</div>
<div>${chairAssignments[hovered.key] ? `배치: ${(getPersonById(chairAssignments[hovered.key]) || { name: "알수없음" }).name}` : "배치 인원 없음"}</div>
`;
tooltip.style.left = `${pointer.x + 14}px`;
tooltip.style.top = `${pointer.y + 14}px`;
tooltip.classList.add("visible");
}
function requestDraw() {
if (rafPending) return;
rafPending = true;
window.requestAnimationFrame(() => {
rafPending = false;
draw();
});
}
function applyWorldTransform() {
ctx.setTransform(
pixelRatio * camera.scale,
0,
0,
-pixelRatio * camera.scale,
pixelRatio * camera.offsetX,
pixelRatio * ((world.maxY + world.minY) * camera.scale + camera.offsetY)
);
}
function draw() {
const rect = canvas.getBoundingClientRect();
ctx.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
ctx.clearRect(0, 0, rect.width, rect.height);
drawGrid(rect.width, rect.height);
const viewA = screenToWorld(0, rect.height);
const viewB = screenToWorld(rect.width, 0);
const viewMinX = Math.min(viewA.x, viewB.x);
const viewMaxX = Math.max(viewA.x, viewB.x);
const viewMinY = Math.min(viewA.y, viewB.y);
const viewMaxY = Math.max(viewA.y, viewB.y);
ctx.save();
applyWorldTransform();
ctx.strokeStyle = "rgba(100, 116, 139, 0.28)";
ctx.lineWidth = 1 / camera.scale;
const tileSize = meta.backgroundTileSize;
const tileMinX = Math.floor(viewMinX / tileSize);
const tileMaxX = Math.floor(viewMaxX / tileSize);
const tileMinY = Math.floor(viewMinY / tileSize);
const tileMaxY = Math.floor(viewMaxY / tileSize);
for (let tx = tileMinX; tx <= tileMaxX; tx += 1) {
for (let ty = tileMinY; ty <= tileMaxY; ty += 1) {
const range = bgTileRanges[`${tx},${ty}`];
if (!range) continue;
const start = range[0];
const count = range[1];
for (let i = start; i < start + count; i += 1) {
const offset = i * 4;
const x1 = bgSegValues[offset] / 10;
const y1 = bgSegValues[offset + 1] / 10;
const x2 = bgSegValues[offset + 2] / 10;
const y2 = bgSegValues[offset + 3] / 10;
if (
Math.max(x1, x2) < viewMinX ||
Math.min(x1, x2) > viewMaxX ||
Math.max(y1, y2) < viewMinY ||
Math.min(y1, y2) > viewMaxY
) continue;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.lineTo(x2, y2);
ctx.stroke();
}
}
}
ctx.restore();
hovered = dragging ? null : pickChair(pointer.x, pointer.y);
ctx.save();
applyWorldTransform();
ctx.lineWidth = 1.45 / camera.scale;
ctx.lineCap = "round";
ctx.lineJoin = "round";
for (const chair of chairGeometry) {
if (chair.maxX < viewMinX || chair.minX > viewMaxX || chair.maxY < viewMinY || chair.minY > viewMaxY) continue;
const active = hovered && hovered.key === chair.key;
const selected = placed.has(chair.key);
const assignedPersonId = chairAssignments[chair.key];
const activePersonChair = activePersonId && assignedPersonId === activePersonId;
const assigned = Boolean(assignedPersonId);
const baseWidth = chair.kind === "block" ? 1.45 : 1.35;
ctx.strokeStyle = activePersonChair
? "rgba(234, 179, 8, 1)"
: assigned
? "rgba(37, 99, 235, 0.98)"
: selected
? "rgba(220, 38, 38, 0.98)"
: active
? "rgba(15, 118, 110, 0.98)"
: chair.kind === "group"
? "rgba(16, 134, 149, 0.74)"
: "rgba(21, 149, 142, 0.8)";
ctx.lineWidth = (activePersonChair ? 2.8 : assigned ? 2.4 : selected ? 2.6 : active ? 2.1 : baseWidth) / camera.scale;
ctx.stroke(chair.path);
}
ctx.restore();
scaleChip.textContent = `scale ${camera.scale.toFixed(4)}x`;
renderTooltip();
}
function persistPlaced() {
localStorage.setItem(STORAGE_KEY, JSON.stringify([...placed]));
}
canvas.addEventListener("pointerdown", (event) => {
dragging = true;
dragStart = { x: event.clientX, y: event.clientY, offsetX: camera.offsetX, offsetY: camera.offsetY };
canvas.classList.add("dragging");
});
window.addEventListener("pointerup", (event) => {
if (dragging && dragStart) {
const move = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
if (move < 4) {
const rect = canvas.getBoundingClientRect();
const picked = pickChair(event.clientX - rect.left, event.clientY - rect.top);
if (picked) {
if (placed.has(picked.key)) placed.delete(picked.key);
else placed.add(picked.key);
persistPlaced();
if (activePersonId) {
const currentChair = getChairByPerson(activePersonId);
if (chairAssignments[picked.key] === activePersonId) {
delete chairAssignments[picked.key];
} else {
if (currentChair && currentChair !== picked.key) delete chairAssignments[currentChair];
chairAssignments[picked.key] = activePersonId;
}
persistAssignments();
renderPeopleList();
}
}
}
}
dragging = false;
dragStart = null;
canvas.classList.remove("dragging");
requestDraw();
});
window.addEventListener("pointermove", (event) => {
const rect = canvas.getBoundingClientRect();
pointer = { x: event.clientX - rect.left, y: event.clientY - rect.top };
if (dragging && dragStart) {
camera.offsetX = dragStart.offsetX + (event.clientX - dragStart.x);
camera.offsetY = dragStart.offsetY + (event.clientY - dragStart.y);
}
requestDraw();
});
canvas.addEventListener("wheel", (event) => {
event.preventDefault();
const rect = canvas.getBoundingClientRect();
const mx = event.clientX - rect.left;
const my = event.clientY - rect.top;
const before = screenToWorld(mx, my);
const factor = event.deltaY < 0 ? 1.08 : 0.92;
camera.scale = Math.max(0.002, Math.min(2, camera.scale * factor));
const after = worldToScreen(before.x, before.y);
camera.offsetX += mx - after.x;
camera.offsetY += my - after.y;
requestDraw();
}, { passive: false });
document.getElementById("fit-btn").addEventListener("click", fit);
document.getElementById("clear-btn").addEventListener("click", () => {
placed.clear();
persistPlaced();
requestDraw();
});
clearAssignBtn.addEventListener("click", () => {
chairAssignments = {};
persistAssignments();
renderPeopleList();
requestDraw();
});
orgChartEl.addEventListener("click", (event) => {
const item = event.target.closest(".org-person[data-person-id]");
if (!item) return;
const personId = item.getAttribute("data-person-id");
activePersonId = personId === activePersonId ? null : personId;
persistActivePerson();
renderPeopleList();
requestDraw();
});
window.addEventListener("resize", resize);
renderPeopleList();
resize();
</script>
</body>
</html>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 276 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 259 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

72
rebuild_asset_codes.cjs Normal file
View File

@@ -0,0 +1,72 @@
const mysql = require('mysql2/promise');
require('dotenv').config({ override: true });
const TYPE_PREFIX_MAP = {
'서버': 'SVR', '워크스테이션': 'SVR', '개인PC': 'PC', '공용PC': 'PC', '서버PC': 'PC',
'저장시스템_렉(NAS)': 'DSS', '저장시스템_렉(DAS)': 'DSS', '저장시스템_미니(NAS)': 'DSS', '저장시스템_미니(DAS)':'DSS',
'저장매체': 'STM', 'HDD': 'HDD', 'SSD': 'SSD',
'노트북': 'NBK', '태블릿': 'TAB',
'드론': 'DRO', '측량장비': 'SUR', '보조기기': 'SUR', '허브': 'NET',
'구독SW': 'SW', '영구SW': 'SW', '내부' : 'SW_INT', '외부':'SW_EXT'
};
const CAT_PREFIX_MAP = {
'서버': 'SVR', 'PC': 'PC', '저장매체': 'STM', '네트워크': 'NET',
'공간정보장비': 'SUR', 'PC부품': 'PRT', '업무지원장비': 'EQP', '시설자산': 'FUR'
};
function getPrefix(cat, type) {
return TYPE_PREFIX_MAP[type] || TYPE_PREFIX_MAP[cat] || CAT_PREFIX_MAP[cat] || 'ETC';
}
(async () => {
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306')
});
const connection = await pool.getConnection();
try {
const [rows] = await connection.query('SELECT id, category, asset_type, purchase_date, asset_code FROM asset_core ORDER BY purchase_date ASC, id ASC');
console.log(`Found ${rows.length} assets to process.`);
// Grouping by prefix and YYMM
const groups = {};
rows.forEach(row => {
const prefix = getPrefix(row.category, row.asset_type);
const datePart = (row.purchase_date && row.purchase_date.length >= 7)
? row.purchase_date.replace(/-/g, '').substring(0, 6) // YYYYMM
: '000000';
const groupKey = `${prefix}-${datePart}`;
if (!groups[groupKey]) groups[groupKey] = [];
groups[groupKey].push(row);
});
let updatedCount = 0;
for (const groupKey in groups) {
const groupAssets = groups[groupKey];
for (let i = 0; i < groupAssets.length; i++) {
const asset = groupAssets[i];
const nextNum = i + 1;
const newCode = `${groupKey}-${String(nextNum).padStart(4, '0')}`;
if (asset.asset_code !== newCode) {
await connection.query('UPDATE asset_core SET asset_code = ? WHERE id = ?', [newCode, asset.id]);
updatedCount++;
}
}
}
console.log(`✅ Successfully rebuilt asset codes. Total updated: ${updatedCount}`);
} catch (err) {
console.error('❌ Error rebuilding asset codes:', err);
} finally {
connection.release();
await pool.end();
}
})();

View File

@@ -0,0 +1,118 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306'),
});
// 하드웨어 출시 연도 데이터베이스 (CPU/GPU)
const RELEASE_DATES = {
// Intel CPU Generations (Mainstream desktop release month/year)
'i9-14': '2023-10', 'i7-14': '2023-10', 'i5-14': '2023-10',
'i9-13': '2022-10', 'i7-13': '2022-10', 'i5-13': '2022-10',
'i9-12': '2021-11', 'i7-12': '2021-11', 'i5-12': '2021-11',
'i9-11': '2021-03', 'i7-11': '2021-03', 'i5-11': '2021-03',
'i9-10': '2020-05', 'i7-10': '2020-05', 'i5-10': '2020-05',
'i9-9': '2018-10', 'i7-9': '2018-10', 'i5-9': '2018-10',
'i7-8': '2017-10', 'i5-8': '2017-10',
'i7-7': '2017-01', 'i5-7': '2017-01',
'i7-6': '2015-08', 'i5-6': '2015-08',
'i7-4': '2013-06', 'i5-4': '2013-06',
'i7-3': '2012-04', 'i5-3': '2012-04',
'i7-2': '2011-01', 'i5-2': '2011-01',
// NVIDIA GPU Series
'RTX 4090': '2022-10', 'RTX 4080': '2022-11', 'RTX 4070': '2023-04', 'RTX 4060': '2023-06',
'RTX 3090': '2020-09', 'RTX 3080': '2020-09', 'RTX 3070': '2020-10', 'RTX 3060': '2021-02',
'RTX 2080': '2018-09', 'RTX 2070': '2018-10', 'RTX 2060': '2019-01',
'GTX 1660': '2019-03', 'GTX 1650': '2019-04',
'GTX 1080': '2016-05', 'GTX 1070': '2016-06', 'GTX 1060': '2016-07', 'GTX 1050': '2016-10',
'GTX 980': '2014-09', 'GTX 970': '2014-09', 'GTX 960': '2015-01'
};
function inferDateFromSpecs(cpu, gpu) {
const cpuStr = (cpu || '').toUpperCase();
const gpuStr = (gpu || '').toUpperCase();
let inferred = null;
// 1. GPU 기준 (최신 그래픽카드가 꽂혀있으면 그 시기 이후 구매일 확률이 높음)
for (const [key, date] of Object.entries(RELEASE_DATES)) {
if (gpuStr.includes(key)) {
inferred = date;
break;
}
}
// 2. CPU 기준 (GPU에서 못 찾았거나, CPU가 더 최신일 경우)
if (!inferred) {
for (const [key, date] of Object.entries(RELEASE_DATES)) {
// i7-13700 등을 찾기 위해 정규식 또는 포함 여부 확인
if (cpuStr.includes(key)) {
inferred = date;
break;
}
}
}
return inferred ? `${inferred}-01` : null;
}
async function run() {
const connection = await pool.getConnection();
try {
const [rows] = await connection.query(`
SELECT c.id, c.asset_code, c.purchase_date, s.cpu, s.gpu
FROM asset_core c
LEFT JOIN asset_spec s ON c.id = s.asset_id
`);
const updates = [];
const unchanged = [];
for (const row of rows) {
const currentVal = (row.purchase_date || '').trim();
// 구매일자가 없거나 부정확한 경우만 처리
if (!currentVal || currentVal === '-' || currentVal === 'undefined' || currentVal.startsWith('2024-01-01')) {
const specDate = inferDateFromSpecs(row.cpu, row.gpu);
if (specDate) {
updates.push({ id: row.id, date: specDate, code: row.asset_code, cpu: row.cpu, gpu: row.gpu });
} else {
unchanged.push({ code: row.asset_code, cpu: row.cpu, gpu: row.gpu });
}
}
}
console.log(`🚀 스펙 분석 결과: ${updates.length}건의 자산 구매일자를 보정합니다.`);
for (const item of updates) {
await connection.query('UPDATE asset_core SET purchase_date = ? WHERE id = ?', [item.date, item.id]);
console.log(`[Update] ${item.code.padEnd(15)} | CPU: ${String(item.cpu).padEnd(20)} | GPU: ${String(item.gpu).padEnd(15)} -> ${item.date}`);
}
if (unchanged.length > 0) {
console.log('\n⚠ 스펙 정보를 찾을 수 없어 보정하지 못한 자산:');
unchanged.forEach(u => {
if (u.code) console.log(`[Skip] ${u.code.padEnd(15)} | CPU: ${u.cpu || '-'} | GPU: ${u.gpu || '-'}`);
});
}
console.log(`\n✅ 완료: ${updates.length}건 보정됨.`);
} catch (err) {
console.error('Error:', err);
} finally {
connection.release();
pool.end();
}
}
run();

128
scratch/fix_dates_strict.js Normal file
View File

@@ -0,0 +1,128 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306'),
});
// 하드웨어 출시 연도/월 데이터베이스
const RELEASE_DATES = {
// Intel CPU
'i9-14': '2023-10', 'i7-14': '2023-10', 'i5-14': '2023-10',
'i9-13': '2022-10', 'i7-13': '2022-10', 'i5-13': '2022-10',
'i9-12': '2021-11', 'i7-12': '2021-11', 'i5-12': '2021-11',
'i9-11': '2021-03', 'i7-11': '2021-03', 'i5-11': '2021-03',
'i9-10': '2020-05', 'i7-10': '2020-05', 'i5-10': '2020-05',
'i9-9': '2018-10', 'i7-9': '2018-10', 'i5-9': '2018-10',
'i7-8': '2017-10', 'i5-8': '2017-10',
'i7-7': '2017-01', 'i5-7': '2017-01',
'i7-6': '2015-08', 'i5-6': '2015-08',
'i7-5': '2014-06', 'i5-5': '2015-06', // Broadwell
'i7-4': '2013-06', 'i5-4': '2013-06',
'i7-3': '2012-04', 'i5-3': '2012-04',
'i7-2': '2011-01', 'i5-2': '2011-01',
// NVIDIA GPU
'RTX 40': '2022-10',
'RTX 30': '2020-09',
'RTX 20': '2018-09',
'GTX 16': '2019-02',
'GTX 10': '2016-05',
'GTX 9': '2014-09',
'GTX 750': '2014-02',
'GTX 7': '2013-05',
'GTX 6': '2012-03'
};
// 출시 연도만 있는 경우 (지시에 따라 후속년도 12월 적용을 위함)
const YEAR_ONLY = {
'I5-4': 2013,
'I5-6': 2015,
'I7-7': 2017,
'GTX 750': 2014
};
function inferDateFromSpecs(cpu, gpu) {
const cpuStr = (cpu || '').toUpperCase();
const gpuStr = (gpu || '').toUpperCase();
let latestYear = 0;
let latestMonth = 0;
// 모든 매핑 데이터를 순회하며 가장 최신 날짜를 찾음
for (const [key, dateStr] of Object.entries(RELEASE_DATES)) {
if (cpuStr.includes(key) || gpuStr.includes(key)) {
const [y, m] = dateStr.split('-').map(Number);
if (y > latestYear || (y === latestYear && m > latestMonth)) {
latestYear = y;
latestMonth = m;
}
}
}
// 매칭된 정보가 있는 경우
if (latestYear > 0) {
// 월 정보가 명확히 매핑된 경우 (RELEASE_DATES 사용)
// 하지만 지시사항에 따라 "월을 못찾으면 12월" & "후속년도" 규칙 적용 여부 판단
// RELEASE_DATES는 월이 이미 있으므로 그대로 사용하되,
// 만약 YEAR_ONLY에만 걸리는 경우를 위해 로직 보강
return `${latestYear}-${String(latestMonth).padStart(2, '0')}-01`;
}
// 연도만 매칭되는 경우 (지시사항: 후속년도 12월)
for (const [key, year] of Object.entries(YEAR_ONLY)) {
if (cpuStr.includes(key) || gpuStr.includes(key)) {
return `${year + 1}-12-01`;
}
}
return null;
}
async function run() {
const connection = await pool.getConnection();
try {
const [rows] = await connection.query(`
SELECT c.id, c.asset_code, c.purchase_date, s.cpu, s.gpu
FROM asset_core c
LEFT JOIN asset_spec s ON c.id = s.asset_id
`);
const updates = [];
for (const row of rows) {
const currentVal = (row.purchase_date || '').trim();
// 구매일자가 없거나 '-', 'undefined'인 경우 + 혹은 아직 보정이 필요한 자산
if (!currentVal || currentVal === '-' || currentVal === 'undefined' || currentVal.startsWith('0000') || currentVal === '2024-01-01') {
const specDate = inferDateFromSpecs(row.cpu, row.gpu);
if (specDate) {
updates.push({ id: row.id, date: specDate, code: row.asset_code, cpu: row.cpu, gpu: row.gpu });
}
}
}
console.log(`🚀 지시사항 반영: ${updates.length}건의 자산을 보정합니다. (후속년도/12월 규칙 적용)`);
for (const item of updates) {
await connection.query('UPDATE asset_core SET purchase_date = ? WHERE id = ?', [item.date, item.id]);
console.log(`[Update] ${item.code.padEnd(15)} | CPU: ${String(item.cpu).padEnd(20)} | GPU: ${String(item.gpu).padEnd(15)} -> ${item.date}`);
}
console.log(`\n✅ 완료: ${updates.length}건 보정됨.`);
} catch (err) {
console.error('Error:', err);
} finally {
connection.release();
pool.end();
}
}
run();

View File

@@ -0,0 +1,88 @@
import mysql from 'mysql2/promise';
import dotenv from 'dotenv';
dotenv.config();
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306'),
});
async function run() {
const connection = await pool.getConnection();
try {
// 먼저 잘못 들어간 0000-00-01 등 복구
console.log('잘못된 형식(0000-00-01 등)을 초기화합니다...');
await connection.query("UPDATE asset_core SET purchase_date = '-' WHERE purchase_date LIKE '0000%' OR purchase_date = '2020-01-01'");
const [rows] = await connection.query('SELECT id, asset_code, purchase_date, category FROM asset_core');
const updates = [];
const missing = [];
for (const row of rows) {
const code = (row.asset_code || '').trim();
const currentVal = (row.purchase_date || '').trim();
// 구매일자가 없거나 '-', 'undefined' 인 경우 대상
if (!currentVal || currentVal === '-' || currentVal === 'undefined') {
let inferredDate = null;
// 1. PREFIX-YYYYMM-NNNN 형식 (예: PC-202406-0001)
const match6 = code.match(/[A-Z]+-(\d{4})(0[1-9]|1[0-2])-\d+/);
if (match6) {
inferredDate = `${match6[1]}-${match6[2]}-01`;
} else {
// 2. PREFIX-YYYYNN 형식 (예: PC-202423) -> 연도만 있고 뒤에 순번 2자리
const matchYearSeq = code.match(/[A-Z]+-(20\d{2})(\d{2})$/);
if (matchYearSeq) {
inferredDate = `${matchYearSeq[1]}-01-01`; // 월을 모르므로 1월로 통일
} else {
// 3. PREFIX-YYNNN 형식 (예: PC-24001)
const matchShort = code.match(/[A-Z]+-(1\d|2\d)(\d{3})/);
if (matchShort) {
inferredDate = `20${matchShort[1]}-01-01`;
}
}
}
// 0000 등의 잘못된 매칭 방지
if (inferredDate && !inferredDate.startsWith('0000')) {
updates.push({ id: row.id, date: inferredDate, code: code });
} else {
missing.push({ id: row.id, code: code, category: row.category });
}
}
}
console.log(`${updates.length}건의 자산을 업데이트합니다.`);
for (const item of updates) {
await connection.query('UPDATE asset_core SET purchase_date = ? WHERE id = ?', [item.date, item.id]);
console.log(`[Update] ${item.code} -> ${item.date}`);
}
console.log('\n--- 구매일자를 추정할 수 없는 자산 목록 ---');
if (missing.length === 0) {
console.log('없음');
} else {
// 중복 제거 및 정렬하여 보고
const uniqueMissing = missing.filter(m => m.code !== '');
uniqueMissing.forEach(m => {
console.log(`[Missing] 코드: ${m.code.padEnd(20)} | 카테고리: ${m.category}`);
});
}
console.log(`\n완료: ${updates.length}건 업데이트됨, ${missing.length}건 미결정.`);
} catch (err) {
console.error('Error:', err);
} finally {
connection.release();
pool.end();
}
}
run();

123
server.js
View File

@@ -4,22 +4,7 @@ import cors from 'cors';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import fs from 'fs'; import fs from 'fs';
dotenv.config(); dotenv.config({ override: true });
const dbConfig = {
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
port: parseInt(process.env.DB_PORT || '3306')
};
const getDbConnectionSummary = () => ({
host: dbConfig.host || '(missing)',
port: dbConfig.port,
user: dbConfig.user || '(missing)',
database: dbConfig.database || '(missing)'
});
const app = express(); const app = express();
app.use(cors()); app.use(cors());
@@ -33,61 +18,19 @@ if (!fs.existsSync('uploads')) {
// MySQL Pool Configuration // MySQL Pool Configuration
const pool = mysql.createPool({ const pool = mysql.createPool({
host: dbConfig.host, host: process.env.DB_HOST,
user: dbConfig.user, user: process.env.DB_USER,
password: dbConfig.password, password: process.env.DB_PASS,
database: dbConfig.database, database: process.env.DB_NAME,
port: dbConfig.port, port: parseInt(process.env.DB_PORT || '3306'),
waitForConnections: true, waitForConnections: true,
connectionLimit: 10, connectionLimit: 10,
queueLimit: 0 queueLimit: 0
}); });
// Database startup check (ensure job_spec_standards table exists)
(async () => {
let connection;
try {
connection = await pool.getConnection();
await connection.query(`
CREATE TABLE IF NOT EXISTS job_spec_standards (
id INT AUTO_INCREMENT PRIMARY KEY,
job_name VARCHAR(100) UNIQUE NOT NULL,
cpu_standard VARCHAR(255),
ram_standard VARCHAR(100),
gpu_standard VARCHAR(100),
min_score INT DEFAULT 0,
remarks TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
`);
console.log('✅ job_spec_standards table verification completed.');
} catch (err) {
console.error('❌ Failed to verify/create job_spec_standards table:', {
db: getDbConnectionSummary(),
code: err.code,
errno: err.errno,
syscall: err.syscall,
address: err.address,
port: err.port,
message: err.message
});
} finally {
if (connection) connection.release();
}
})();
// Error Handler // Error Handler
const handleError = (res, err, label) => { const handleError = (res, err, label) => {
console.error(`❌ [${label}] Error:`, { console.error(`❌ [${label}] Error:`, err);
db: getDbConnectionSummary(),
code: err.code,
errno: err.errno,
syscall: err.syscall,
address: err.address,
port: err.port,
message: err.message
});
res.status(500).json({ error: err.message }); res.status(500).json({ error: err.message });
}; };
@@ -208,7 +151,6 @@ app.get('/api/assets/master', async (req, res) => {
const [users] = await connection.query('SELECT * FROM system_users'); const [users] = await connection.query('SELECT * FROM system_users');
const [logs] = await connection.query('SELECT * FROM asset_history ORDER BY created_at DESC'); const [logs] = await connection.query('SELECT * FROM asset_history ORDER BY created_at DESC');
const [partsMaster] = await connection.query('SELECT * FROM hardware_components_master ORDER BY category, component_name'); const [partsMaster] = await connection.query('SELECT * FROM hardware_components_master ORDER BY category, component_name');
const [jobSpecs] = await connection.query('SELECT * FROM job_spec_standards ORDER BY job_name');
masterData.swInternal = swInternal; masterData.swInternal = swInternal;
masterData.swExternal = swExternal; masterData.swExternal = swExternal;
@@ -216,7 +158,6 @@ app.get('/api/assets/master', async (req, res) => {
masterData.users = users; masterData.users = users;
masterData.logs = logs; masterData.logs = logs;
masterData.partsMaster = partsMaster; masterData.partsMaster = partsMaster;
masterData.jobSpecs = jobSpecs;
res.json(masterData); res.json(masterData);
} catch (err) { } catch (err) {
@@ -605,56 +546,6 @@ app.delete('/api/hardware-components/:id', async (req, res) => {
} }
}); });
// 6.7.1. Get Job Spec Standards
app.get('/api/job-specs', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM job_spec_standards ORDER BY job_name');
res.json(rows);
} catch (err) {
handleError(res, err, 'GET JOB SPECS');
}
});
// 6.7.2. Save Job Spec Standard (Add or Update)
app.post('/api/job-specs/save', async (req, res) => {
const { id, job_name, cpu_standard, ram_standard, gpu_standard, min_score, remarks } = req.body;
let connection;
try {
connection = await pool.getConnection();
if (id) {
await connection.query(
'UPDATE job_spec_standards SET job_name = ?, cpu_standard = ?, ram_standard = ?, gpu_standard = ?, min_score = ?, remarks = ? WHERE id = ?',
[job_name, cpu_standard, ram_standard, gpu_standard, min_score, remarks, id]
);
} else {
await connection.query(
'INSERT INTO job_spec_standards (job_name, cpu_standard, ram_standard, gpu_standard, min_score, remarks) VALUES (?, ?, ?, ?, ?, ?)',
[job_name, cpu_standard, ram_standard, gpu_standard, min_score, remarks]
);
}
res.json({ success: true });
} catch (err) {
handleError(res, err, 'SAVE JOB SPEC');
} finally {
if (connection) connection.release();
}
});
// 6.7.3. Delete Job Spec Standard
app.delete('/api/job-specs/:id', async (req, res) => {
const { id } = req.params;
let connection;
try {
connection = await pool.getConnection();
await connection.query('DELETE FROM job_spec_standards WHERE id = ?', [id]);
res.json({ success: true });
} catch (err) {
handleError(res, err, 'DELETE JOB SPEC');
} finally {
if (connection) connection.release();
}
});
// 6.8. Get System Users List // 6.8. Get System Users List
app.get('/api/system-users', async (req, res) => { app.get('/api/system-users', async (req, res) => {
try { try {

View File

@@ -4,14 +4,14 @@ import { createIcons, X } from 'lucide';
const DASHBOARD_DETAIL_MODAL_HTML = ` const DASHBOARD_DETAIL_MODAL_HTML = `
<div id="dashboard-detail-modal" class="modal-overlay hidden"> <div id="dashboard-detail-modal" class="modal-overlay hidden">
<div class="modal-content wide" style="max-width: 1000px;"> <div class="modal-content wide">
<div class="modal-header"> <div class="modal-header">
<h2 id="dashboard-detail-modal-title">상세 목록</h2> <h2 id="dashboard-detail-modal-title" class="modal-title">상세 목록</h2>
<button id="btn-close-dashboard-detail-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button> <button id="btn-close-dashboard-detail-modal" class="btn-icon" aria-label="닫기"><i data-lucide="x"></i></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="table-container"> <div class="table-container">
<table style="width:100%;"> <table>
<thead></thead> <thead></thead>
<tbody id="dashboard-detail-tbody"></tbody> <tbody id="dashboard-detail-tbody"></tbody>
</table> </table>

View File

@@ -21,51 +21,14 @@ class HwAssetModal extends BaseModal {
} }
protected renderFrameHTML(): string { protected renderFrameHTML(): string {
const sharedStyle = 'height: 38px !important; box-sizing: border-box !important; font-size: 13px; margin: 0;';
const inputStyle = sharedStyle;
const btnStyle = `padding: 0 16px; display: inline-flex; align-items: center; justify-content: center; font-weight: 600; white-space: nowrap; cursor: pointer; ${sharedStyle}`;
return ` return `
<style>
.autocomplete-list {
position: absolute;
top: 100%;
left: 0;
right: 0;
max-height: 150px;
overflow-y: auto;
background-color: white;
border: 1px solid var(--border-color, #E2E8F0);
border-top: none;
border-radius: 0 0 4px 4px;
z-index: 1000;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.autocomplete-item {
padding: 8px 12px;
font-size: 13px;
color: #334155;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.autocomplete-item:hover {
background-color: #F1F5F9;
color: #1E5149;
font-weight: 600;
}
.hidden {
display: none !important;
}
</style>
<div id="hw-asset-modal" class="modal-overlay hidden"> <div id="hw-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide"> <div class="modal-content wide">
<div class="modal-header"> <div class="modal-header">
<h2 id="hw-modal-title" style="margin: 0; font-size: 18px; font-weight: 800; color: white;">${this.title}</h2> <h2 id="hw-modal-title" class="modal-title">${this.title}</h2>
<button id="btn-close-hw-modal" class="btn-icon" aria-label="닫기" style="font-size: 28px; color: white; background: none; border: none; cursor: pointer; line-height: 1;">&times;</button> <button id="btn-close-hw-modal" class="btn-icon" aria-label="닫기">&times;</button>
</div> </div>
<div class="modal-body" style="padding: 24px; overflow-y: auto;"> <div class="modal-body">
<div class="modal-body-split"> <div class="modal-body-split">
<div class="modal-form-area"> <div class="modal-form-area">
<form id="hw-asset-form" class="grid-form"> <form id="hw-asset-form" class="grid-form">
@@ -73,215 +36,218 @@ class HwAssetModal extends BaseModal {
<input type="hidden" id="hw-remotes-data" name="remotes" /> <input type="hidden" id="hw-remotes-data" name="remotes" />
<!-- [SECTION 1] 기본 관리 정보 --> <!-- [SECTION 1] 기본 관리 정보 -->
<div class="form-section-title" style="padding-top: 0; margin-bottom: 12px;">기본 관리 정보</div> <div class="form-section-title">기본 관리 정보</div>
<div class="form-group"> <div class="form-group">
<label>${ASSET_SCHEMA.ASSET_CODE.ui}</label> <label>${ASSET_SCHEMA.ASSET_CODE.ui}</label>
<div class="input-with-btn" style="display: flex; gap: 8px; align-items: stretch;"> <div class="input-with-btn">
<input type="text" id="hw-asset_code" name="asset_code" placeholder="자동 생성" readonly style="flex: 1; ${inputStyle}" /> <input type="text" id="hw-asset_code" name="asset_code" placeholder="자동 생성" readonly />
<button type="button" id="btn-gen-hw-code" class="btn btn-outline" style="${btnStyle}">생성</button> <button type="button" id="btn-gen-hw-code" class="btn btn-outline">생성</button>
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>${ASSET_SCHEMA.PURCHASE_CORP.ui}</label> <label>${ASSET_SCHEMA.PURCHASE_CORP.ui}</label>
<select id="hw-purchase_corp" name="purchase_corp" style="${inputStyle}">${generateOptionsHTML(CORP_LIST)}</select> <select id="hw-purchase_corp" name="purchase_corp">${generateOptionsHTML(CORP_LIST)}</select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>${ASSET_SCHEMA.CATEGORY.ui}</label> <label>${ASSET_SCHEMA.CATEGORY.ui}</label>
<select id="hw-category" name="category" style="${inputStyle}"> <select id="hw-category" name="category">
<option value="">선택</option> <option value="">선택</option>
${generateOptionsHTML(Object.keys(CATEGORY_TYPE_MAP), '', false)} ${generateOptionsHTML(Object.keys(CATEGORY_TYPE_MAP), '', false)}
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>${ASSET_SCHEMA.ASSET_TYPE.ui}</label> <label>${ASSET_SCHEMA.ASSET_TYPE.ui}</label>
<select id="hw-asset_type" name="asset_type" style="${inputStyle}"> <select id="hw-asset_type" name="asset_type">
<option value="">구분을 먼저 선택하세요</option> <option value="">구분을 먼저 선택하세요</option>
</select> </select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>${ASSET_SCHEMA.HW_STATUS.ui}</label> <label>${ASSET_SCHEMA.HW_STATUS.ui}</label>
<select id="hw-hw_status" name="hw_status" style="${inputStyle}">${generateOptionsHTML(HW_STATUS_LIST)}</select> <select id="hw-hw_status" name="hw_status">${generateOptionsHTML(HW_STATUS_LIST)}</select>
</div> </div>
<div class="form-group"> <div class="form-group">
<label>${ASSET_SCHEMA.SERVICE_TYPE.ui}</label> <label>${ASSET_SCHEMA.SERVICE_TYPE.ui}</label>
<select id="hw-service_type" name="service_type" style="${inputStyle}"> <select id="hw-service_type" name="service_type">
<option value="외부">외부</option> <option value="외부">외부</option>
<option value="내부">내부</option> <option value="내부">내부</option>
</select> </select>
</div> </div>
<div class="form-group full-width" style="grid-column: span 2;"> <div class="form-group full-width">
<label>${ASSET_SCHEMA.ASSET_PURPOSE.ui}</label> <label>${ASSET_SCHEMA.ASSET_PURPOSE.ui}</label>
<input type="text" id="hw-asset_purpose" name="asset_purpose" placeholder="자산의 용도를 입력하세요" style="${inputStyle} width: 100%;" /> <input type="text" id="hw-asset_purpose" name="asset_purpose" placeholder="자산의 용도를 입력하세요" />
</div> </div>
<div class="form-group infra-only monitoring-field"> <div class="form-group infra-only monitoring-field">
<label>${ASSET_SCHEMA.MONITORING.ui}</label> <label>${ASSET_SCHEMA.MONITORING.ui}</label>
<select id="hw-monitoring" name="monitoring" style="${inputStyle}"> <select id="hw-monitoring" name="monitoring">
<option value="비대상">비대상</option> <option value="비대상">비대상</option>
<option value="대상">대상</option> <option value="대상">대상</option>
</select> </select>
</div> </div>
<!-- [SECTION 2] 조직 및 사용자 정보 --> <!-- [SECTION 2] 조직 및 사용자 정보 -->
<div class="form-section-title" style="margin-top: 24px; margin-bottom: 12px;">사용자 및 조직 정보</div> <div class="form-section-title org-user-section">사용자 및 조직 정보</div>
<div class="form-group"> <div class="form-group org-user-field">
<label>${ASSET_SCHEMA.CURRENT_DEPT.ui}</label> <label>${ASSET_SCHEMA.CURRENT_DEPT.ui}</label>
<select id="hw-current_dept" name="current_dept" style="${inputStyle}">${generateOptionsHTML(ORG_LIST)}</select> <select id="hw-current_dept" name="current_dept">${generateOptionsHTML(ORG_LIST)}</select>
</div> </div>
<div class="form-group"> <div class="form-group org-user-field">
<label>${ASSET_SCHEMA.MANAGER_MAIN.ui}</label> <label>${ASSET_SCHEMA.MANAGER_MAIN.ui}</label>
<input type="text" id="hw-manager_primary" name="manager_primary" style="${inputStyle}" /> <input type="text" id="hw-manager_primary" name="manager_primary" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>${ASSET_SCHEMA.MANAGER_SUB.ui}</label> <label>${ASSET_SCHEMA.MANAGER_SUB.ui}</label>
<input type="text" id="hw-manager_secondary" name="manager_secondary" style="${inputStyle}" /> <input type="text" id="hw-manager_secondary" name="manager_secondary" />
</div> </div>
<div class="form-group personal-only"> <div class="form-group personal-only">
<label>${ASSET_SCHEMA.CURRENT_USER.ui}</label> <label>${ASSET_SCHEMA.CURRENT_USER.ui}</label>
<input type="text" id="hw-user_current" name="user_current" style="${inputStyle}" /> <input type="text" id="hw-user_current" name="user_current" />
</div> </div>
<div class="form-group personal-only"> <div class="form-group personal-only">
<label>${ASSET_SCHEMA.USER_POSITION.ui}</label> <label>${ASSET_SCHEMA.USER_POSITION.ui}</label>
<input type="text" id="hw-user_position" name="user_position" style="${inputStyle}" /> <input type="text" id="hw-user_position" name="user_position" />
</div> </div>
<div class="form-group personal-only"> <div class="form-group personal-only">
<label>${ASSET_SCHEMA.PREV_USER.ui}</label> <label>${ASSET_SCHEMA.PREV_USER.ui}</label>
<input type="text" id="hw-previous_user" name="previous_user" style="${inputStyle}" /> <input type="text" id="hw-previous_user" name="previous_user" />
</div> </div>
<!-- [SECTION 3] 하드웨어 사양 --> <!-- [SECTION 3] 하드웨어 사양 -->
<div class="form-section-title hardware-section" style="margin-top: 24px; margin-bottom: 12px;">시스템 사양 정보</div> <div class="form-section-title hardware-section">시스템 사양 정보</div>
<div class="form-group"> <div class="form-group">
<label>${ASSET_SCHEMA.MODEL_NAME.ui}</label> <label>${ASSET_SCHEMA.MODEL_NAME.ui}</label>
<input type="text" id="hw-model_name" name="model_name" style="${inputStyle}" /> <input type="text" id="hw-model_name" name="model_name" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>${ASSET_SCHEMA.ASSET_MFR.ui}</label> <label>${ASSET_SCHEMA.ASSET_MFR.ui}</label>
<input type="text" id="hw-asset_mfr" name="asset_mfr" style="${inputStyle}" /> <input type="text" id="hw-asset_mfr" name="asset_mfr" />
</div> </div>
<div class="form-group sn-only"> <div class="form-group sn-only">
<label>${ASSET_SCHEMA.SERIAL_NUM.ui}</label> <label>${ASSET_SCHEMA.SERIAL_NUM.ui}</label>
<input type="text" id="hw-serial_num" name="serial_num" style="${inputStyle}" /> <input type="text" id="hw-serial_num" name="serial_num" />
</div> </div>
<div class="form-group spec-only"> <div class="form-group spec-only">
<label>${ASSET_SCHEMA.OS.ui}</label> <label>${ASSET_SCHEMA.OS.ui}</label>
<input type="text" id="hw-os" name="os" style="${inputStyle}" /> <input type="text" id="hw-os" name="os" />
</div> </div>
<div class="form-group spec-only" style="position: relative;"> <div class="form-group spec-only" style="position: relative;">
<label>${ASSET_SCHEMA.CPU.ui}</label> <label>${ASSET_SCHEMA.CPU.ui}</label>
<input type="text" id="hw-cpu" name="cpu" autocomplete="off" placeholder="CPU 부품 검색..." style="${inputStyle}" /> <input type="text" id="hw-cpu" name="cpu" autocomplete="off" placeholder="CPU 부품 검색..." />
<div id="hw-cpu-autocomplete" class="autocomplete-list hidden"></div> <div id="hw-cpu-autocomplete" class="autocomplete-list hidden"></div>
</div> </div>
<div class="form-group spec-only" style="position: relative;"> <div class="form-group spec-only" style="position: relative;">
<label>${ASSET_SCHEMA.RAM.ui}</label> <label>${ASSET_SCHEMA.RAM.ui}</label>
<input type="text" id="hw-ram" name="ram" autocomplete="off" placeholder="RAM 부품 검색..." style="${inputStyle}" /> <input type="text" id="hw-ram" name="ram" autocomplete="off" placeholder="RAM 부품 검색..." />
<div id="hw-ram-autocomplete" class="autocomplete-list hidden"></div> <div id="hw-ram-autocomplete" class="autocomplete-list hidden"></div>
</div> </div>
<div class="form-group spec-only" style="position: relative;"> <div class="form-group spec-only" style="position: relative;">
<label>${ASSET_SCHEMA.GPU.ui}</label> <label>${ASSET_SCHEMA.GPU.ui}</label>
<input type="text" id="hw-gpu" name="gpu" autocomplete="off" placeholder="GPU 부품 검색..." style="${inputStyle}" /> <input type="text" id="hw-gpu" name="gpu" autocomplete="off" placeholder="GPU 부품 검색..." />
<div id="hw-gpu-autocomplete" class="autocomplete-list hidden"></div> <div id="hw-gpu-autocomplete" class="autocomplete-list hidden"></div>
</div> </div>
<div class="form-group spec-only"> <div class="form-group spec-only">
<label>${ASSET_SCHEMA.MAINBOARD.ui}</label> <label>${ASSET_SCHEMA.MAINBOARD.ui}</label>
<input type="text" id="hw-mainboard" name="mainboard" style="${inputStyle}" /> <input type="text" id="hw-mainboard" name="mainboard" />
</div> </div>
<div class="form-group spec-only"> <div class="form-group spec-only">
<label>성능 등급</label> <label>성능 등급</label>
<div id="hw-pc-grade-container" style="display: flex; align-items: center; height: 38px;"> <div id="hw-pc-grade-container" class="grade-badge-container">
<span class="badge b-yellow" id="hw-pc-grade-badge">-</span> <span class="badge b-yellow" id="hw-pc-grade-badge">-</span>
</div> </div>
</div> </div>
<div class="form-group monitor-only"> <div class="form-group monitor-only">
<label>${ASSET_SCHEMA.MONITOR_INCH.ui}</label> <label>${ASSET_SCHEMA.MONITOR_INCH.ui}</label>
<input type="text" id="hw-monitor_inch" name="monitor_inch" style="${inputStyle}" /> <input type="text" id="hw-monitor_inch" name="monitor_inch" />
</div> </div>
<!-- 동적 디스크 할당 영역 --> <!-- 동적 디스크 할당 영역 -->
<div class="form-section-title spec-only" style="margin-top: 24px; margin-bottom: 12px;">디스크(용량) 정보</div> <div class="form-section-title spec-only">디스크(용량) 정보</div>
<div class="form-group spec-only full-width" style="grid-column: span 2;"> <div class="form-group spec-only full-width">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;"> <div class="history-header">
<label style="margin: 0; font-size: 11px; font-weight: 700; color: var(--text-muted);">연결된 드라이브 리스트</label> <label>연결된 드라이브 리스트</label>
<button type="button" id="btn-add-volume" class="btn btn-outline" style="height: 26px !important; padding: 0 10px; font-size: 11px; display: none;">+ 볼륨 추가</button> <button type="button" id="btn-add-volume" class="btn btn-outline btn-sm hidden">+ 볼륨 추가</button>
</div> </div>
<div id="hw-volume-container" style="display: flex; flex-direction: column; gap: 8px;"></div> <div id="hw-volume-container" class="volume-container"></div>
<input type="hidden" id="hw-volumes-data" name="volumes" /> <input type="hidden" id="hw-volumes-data" name="volumes" />
</div> </div>
<!-- 통합 원격 접속 정보 영역 --> <!-- 통합 원격 접속 정보 영역 -->
<div class="form-section-title net-only" style="margin-top: 24px; margin-bottom: 12px;">네트워크 및 원격 접속 정보</div> <div class="form-section-title net-only">네트워크 및 원격 접속 정보</div>
<div class="form-group net-only full-width" style="grid-column: span 2;"> <div class="form-group net-only full-width">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;"> <div class="history-header">
<label style="margin: 0; font-size: 11px; font-weight: 700; color: var(--text-muted);">IP/MAC 및 접속 계정 정보</label> <label>IP/MAC 및 접속 계정 정보</label>
<button type="button" id="btn-add-remote-info" class="btn btn-outline" style="height: 26px !important; padding: 0 10px; font-size: 11px; display: none;">+ 접속 정보 추가</button> <button type="button" id="btn-add-remote-info" class="btn btn-outline btn-sm hidden">+ 접속 정보 추가</button>
</div> </div>
<div id="hw-remote-info-container" style="display: flex; flex-direction: column; gap: 12px;"></div> <div id="hw-remote-info-container" class="remote-info-container"></div>
</div> </div>
<!-- [SECTION 5] 설치 위치 --> <!-- [SECTION 5] 설치 위치 -->
<div class="form-section-title location-section" style="margin-top: 24px; margin-bottom: 12px;">설치 위치</div> <div class="form-section-title location-section">설치 위치</div>
<div class="form-group location-field"> <div class="form-group location-field">
<label>건물/위치</label> <label>건물/위치</label>
<select id="hw-bldg-select" name="location" style="${inputStyle}">${generateOptionsHTML(Object.keys(LOCATION_DATA))}</select> <select id="hw-bldg-select" name="location">${generateOptionsHTML(Object.keys(LOCATION_DATA))}</select>
</div> </div>
<div class="form-group location-field"> <div class="form-group location-field">
<label>${ASSET_SCHEMA.LOC_DETAIL.ui}</label> <label>${ASSET_SCHEMA.LOC_DETAIL.ui}</label>
<div class="input-with-btn" style="display: flex; gap: 8px; align-items: stretch;"> <div class="input-with-btn">
<select id="hw-location_detail" name="location_detail" style="flex: 1; ${inputStyle}"><option value="">선택</option></select> <select id="hw-location_detail" name="location_detail"><option value="">선택</option></select>
<button type="button" id="btn-reg-loc-map" class="btn btn-primary" style="${btnStyle} display: none;">위치등록</button> <button type="button" id="btn-reg-loc-map" class="btn btn-primary hidden">위치등록</button>
<button type="button" id="btn-view-loc-map" class="btn btn-primary btn-loc-action btn-loc-view" style="${btnStyle} display: none; pointer-events: auto !important; cursor: pointer !important;">위치보기</button> <button type="button" id="btn-view-loc-map" class="btn btn-primary btn-loc-action btn-loc-view hidden">위치보기</button>
</div> </div>
<input type="hidden" id="hw-loc_x" name="loc_x" /><input type="hidden" id="hw-loc_y" name="loc_y" /><input type="hidden" id="hw-location_photo" name="location_photo" /> <input type="hidden" id="hw-loc_x" name="loc_x" /><input type="hidden" id="hw-loc_y" name="loc_y" /><input type="hidden" id="hw-location_photo" name="location_photo" />
</div> </div>
<!-- [SECTION 6] 구매 정보 --> <!-- [SECTION 6] 구매 정보 -->
<div class="form-section-title" style="margin-top: 24px; margin-bottom: 12px;">구매 및 증빙 정보</div> <div class="form-section-title">구매 및 증빙 정보</div>
<div class="form-group"> <div class="form-group">
<label>${ASSET_SCHEMA.PURCHASE_DATE.ui}</label> <label>${ASSET_SCHEMA.PURCHASE_DATE.ui}</label>
<input type="text" id="hw-purchase_date" name="purchase_date" placeholder="YYYY-MM-DD" style="${inputStyle}" /> <input type="text" id="hw-purchase_date" name="purchase_date" placeholder="YYYY-MM-DD" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>${ASSET_SCHEMA.PURCHASE_VENDOR.ui}</label> <label>${ASSET_SCHEMA.PURCHASE_VENDOR.ui}</label>
<input type="text" id="hw-purchase_vendor" name="purchase_vendor" style="${inputStyle}" /> <input type="text" id="hw-purchase_vendor" name="purchase_vendor" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>${ASSET_SCHEMA.PURCHASE_AMOUNT.ui}</label> <label>${ASSET_SCHEMA.PURCHASE_AMOUNT.ui}</label>
<input type="text" id="hw-purchase_amount" name="purchase_amount" placeholder="0" style="${inputStyle}" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\B(?=(\\d{3})+(?!\\d))/g, ',')" /> <input type="text" id="hw-purchase_amount" name="purchase_amount" placeholder="0" oninput="this.value = this.value.replace(/[^0-9]/g, '').replace(/\\\\B(?=(\\\\d{3})+(?!\\\\d))/g, ',')" />
</div> </div>
<div class="form-group"> <div class="form-group">
<label>${ASSET_SCHEMA.APPROVAL_DOC.ui} (첨부파일)</label> <label>${ASSET_SCHEMA.APPROVAL_DOC.ui} (첨부파일)</label>
<div class="file-upload-wrapper"> <div class="file-upload-wrapper">
<input type="file" id="hw-approval_document_file" style="display:none;" /> <input type="file" id="hw-approval_document_file" style="display:none;" />
<div class="input-with-btn" style="display: flex; gap: 8px; align-items: stretch;"> <div class="input-with-btn">
<button type="button" id="btn-file-select" onclick="document.getElementById('hw-approval_document_file').click()" class="btn btn-outline btn-loc-action" style="${btnStyle} flex: 1; justify-content: flex-start; pointer-events: auto !important; cursor: pointer !important;"> <button type="button" id="btn-file-select" onclick="document.getElementById('hw-approval_document_file').click()" class="btn btn-outline btn-loc-action">
<span id="hw-file-name-display">파일 선택...</span> <span id="hw-file-name-display">파일 선택...</span>
</button> </button>
</div> </div>
<input type="hidden" id="hw-approval_document" name="approval_document" /> <input type="hidden" id="hw-approval_document" name="approval_document" />
<div id="hw-file-link-container" style="margin-top: 4px;"></div> <div id="hw-file-link-container"></div>
</div> </div>
</div> </div>
<div class="form-group full-width"> <div class="form-group full-width">
<label>${ASSET_SCHEMA.MEMO.ui}</label> <label>${ASSET_SCHEMA.MEMO.ui}</label>
<textarea id="hw-memo" name="memo" rows="3" style="width: 100%; padding: 10px; border: 1px solid var(--border-color); border-radius: 4px; font-family: inherit; font-size: 13px; resize: none !important; box-sizing: border-box;"></textarea> <textarea id="hw-memo" name="memo" rows="3"></textarea>
</div> </div>
</form> </form>
</div> </div>
<div class="modal-history-area"> <div class="modal-history-area">
<div class="history-header" style="border-bottom: 1px solid var(--border-color); padding-bottom: 12px; margin-bottom: 16px;"> <div class="history-header">
<h3 style="margin: 0; font-size: 14px; font-weight: 800;">자산 변동 이력</h3> <h3>자산 변동 이력</h3>
<button type="button" id="btn-add-hw-log" class="btn btn-outline btn-sm" style="height: 30px; font-size: 11px;">이력 추가</button> <div class="history-actions">
<button type="button" id="btn-view-asset-flow" class="btn btn-outline btn-sm">흐름 보기</button>
<button type="button" id="btn-add-hw-log" class="btn btn-outline btn-sm">이력 추가</button>
</div>
</div> </div>
<div id="hw-history-list" class="history-timeline"></div> <div id="hw-history-list" class="history-timeline"></div>
</div> </div>
</div> </div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<button id="btn-delete-hw-asset" class="btn btn-outline btn-danger" style="height: 42px;">삭제</button> <button id="btn-delete-hw-asset" class="btn btn-outline btn-danger">삭제</button>
<div class="footer-actions"> <div class="footer-actions">
<button id="btn-revert-hw-edit" class="btn btn-outline hidden" style="height: 42px;">수정 취소</button> <button id="btn-revert-hw-edit" class="btn btn-outline hidden">수정 취소</button>
<button id="btn-cancel-hw-modal" class="btn btn-outline" style="height: 42px;">닫기</button> <button id="btn-cancel-hw-modal" class="btn btn-outline">닫기</button>
<button id="btn-save-hw-asset" class="btn btn-primary" style="height: 42px;">저장</button> <button id="btn-save-hw-asset" class="btn btn-primary">저장</button>
</div> </div>
</div> </div>
</div> </div>
@@ -506,7 +472,7 @@ class HwAssetModal extends BaseModal {
row.style.display = 'flex'; row.style.gap = '8px'; row.style.alignItems = 'center'; row.style.display = 'flex'; row.style.gap = '8px'; row.style.alignItems = 'center';
const inputStyle = 'height: 38px !important; box-sizing: border-box !important; font-size: 13px; margin: 0; padding: 0 8px;'; const inputStyle = 'height: 38px !important; box-sizing: border-box !important; font-size: 13px; margin: 0; padding: 0 8px;';
row.innerHTML = ` row.innerHTML = `
<select class="vol-type" style="${inputStyle} width: 80px;" ${!this.isEditMode ? 'disabled' : ''}> <select class="vol-type" ${!this.isEditMode ? 'disabled' : ''}>
<option value="SSD" ${vol.type === 'SSD' ? 'selected' : ''}>SSD</option> <option value="SSD" ${vol.type === 'SSD' ? 'selected' : ''}>SSD</option>
<option value="HDD" ${vol.type === 'HDD' ? 'selected' : ''}>HDD</option> <option value="HDD" ${vol.type === 'HDD' ? 'selected' : ''}>HDD</option>
<option value="NVMe" ${vol.type === 'NVMe' ? 'selected' : ''}>NVMe</option> <option value="NVMe" ${vol.type === 'NVMe' ? 'selected' : ''}>NVMe</option>
@@ -558,7 +524,8 @@ class HwAssetModal extends BaseModal {
// Second Line: Tool & Credentials (Only for IP) // Second Line: Tool & Credentials (Only for IP)
const line2 = document.createElement('div'); const line2 = document.createElement('div');
line2.className = 'ri-line ri-cred-line'; line2.className = 'ri-line ri-cred-line';
line2.style.display = info.type === 'IP' ? 'flex' : 'none'; if (info.type !== 'IP') line2.classList.add('hidden');
line2.innerHTML = ` line2.innerHTML = `
<div class="ri-connector"></div> <div class="ri-connector"></div>
<select class="ri-tool" ${!this.isEditMode ? 'disabled' : ''}> <select class="ri-tool" ${!this.isEditMode ? 'disabled' : ''}>
@@ -568,7 +535,7 @@ class HwAssetModal extends BaseModal {
</select> </select>
<input type="text" class="ri-id" value="${parsedId}" placeholder="원격 ID" ${!this.isEditMode ? 'readonly' : ''} /> <input type="text" class="ri-id" value="${parsedId}" placeholder="원격 ID" ${!this.isEditMode ? 'readonly' : ''} />
<input type="text" class="ri-pw" value="${parsedPw}" placeholder="원격 PW" ${!this.isEditMode ? 'readonly' : ''} /> <input type="text" class="ri-pw" value="${parsedPw}" placeholder="원격 PW" ${!this.isEditMode ? 'readonly' : ''} />
<div class="ri-spacer"></div> <!-- Spacer for the remove button width --> <div class="ri-spacer"></div>
`; `;
row.appendChild(line1); row.appendChild(line1);
@@ -578,7 +545,7 @@ class HwAssetModal extends BaseModal {
const typeSelect = row.querySelector('.ri-type') as HTMLSelectElement; const typeSelect = row.querySelector('.ri-type') as HTMLSelectElement;
typeSelect.addEventListener('change', (e) => { typeSelect.addEventListener('change', (e) => {
const isIP = (e.target as HTMLSelectElement).value === 'IP'; const isIP = (e.target as HTMLSelectElement).value === 'IP';
line2.style.display = isIP ? 'flex' : 'none'; line2.classList.toggle('hidden', !isIP);
if (!isIP) { if (!isIP) {
(row.querySelector('.ri-id') as HTMLInputElement).value = ''; (row.querySelector('.ri-id') as HTMLInputElement).value = '';
(row.querySelector('.ri-pw') as HTMLInputElement).value = ''; (row.querySelector('.ri-pw') as HTMLInputElement).value = '';

View File

@@ -1,284 +0,0 @@
import { state, saveJobSpec, deleteJobSpec } from '../../core/state';
import { BaseModal } from './BaseModal';
import { setFieldValue } from './ModalUtils';
import { UI_TEXT } from '../../core/schema';
import { calculatePcScoreDeductive } from '../../core/utils';
class JobSpecModal extends BaseModal {
constructor() {
super('job-spec', '직무별 기준 사양');
}
protected renderFrameHTML(): string {
const sharedStyle = 'height: 38px !important; box-sizing: border-box !important; font-size: 13px; margin: 0;';
const inputStyle = sharedStyle;
return `
<div id="job-spec-asset-modal" class="modal-overlay hidden">
<style>
.autocomplete-list {
position: absolute;
top: 100%;
left: 0;
right: 0;
max-height: 150px;
overflow-y: auto;
background-color: white;
border: 1px solid var(--border-color, #E2E8F0);
border-top: none;
border-radius: 0 0 4px 4px;
z-index: 1000;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
.autocomplete-item {
padding: 8px 12px;
font-size: 13px;
color: #334155;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.autocomplete-item:hover {
background-color: #F1F5F9;
color: #1E5149;
font-weight: 600;
}
.hidden {
display: none !important;
}
</style>
<div class="modal-content" style="max-width: 500px; width: 100%;">
<div class="modal-header">
<h2 id="job-spec-modal-title" style="margin: 0; font-size: 18px; font-weight: 800; color: white;">\${this.title}</h2>
<button id="btn-close-job-spec-modal" class="btn-icon" aria-label="닫기" style="font-size: 28px; color: white; background: none; border: none; cursor: pointer; line-height: 1;">&times;</button>
</div>
<div class="modal-body" style="padding: 24px; overflow-y: auto;">
<form id="job-spec-asset-form" class="grid-form" style="display: flex; flex-direction: column; gap: 16px;">
<input type="hidden" id="job-spec-id" name="id" />
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">직무명</label>
<input type="text" id="job-spec-job-name" name="job_name" placeholder="예: BIM 모델러, 개발자, 엔지니어" required style="\${inputStyle} width: 100%;" />
</div>
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px; position: relative;">
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">권장 CPU 사양</label>
<input type="text" id="job-spec-cpu-standard" name="cpu_standard" placeholder="CPU 검색..." required style="\${inputStyle} width: 100%;" autocomplete="off" />
<div id="job-spec-cpu-autocomplete" class="autocomplete-list hidden"></div>
</div>
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px; position: relative;">
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">권장 RAM 사양</label>
<input type="text" id="job-spec-ram-standard" name="ram_standard" placeholder="RAM 검색..." required style="\${inputStyle} width: 100%;" autocomplete="off" />
<div id="job-spec-ram-autocomplete" class="autocomplete-list hidden"></div>
</div>
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px; position: relative;">
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">권장 GPU 사양</label>
<input type="text" id="job-spec-gpu-standard" name="gpu_standard" placeholder="GPU 검색..." required style="\${inputStyle} width: 100%;" autocomplete="off" />
<div id="job-spec-gpu-autocomplete" class="autocomplete-list hidden"></div>
</div>
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">성능 기준 점수 (이상, 자동 계산됨)</label>
<input type="number" id="job-spec-min-score" name="min_score" placeholder="자동 계산 대기..." required style="\${inputStyle} width: 100%;" readonly />
</div>
<div class="form-group" style="display: flex; flex-direction: column; gap: 6px;">
<label style="font-size: 11px; font-weight: 700; color: var(--text-muted);">비고 (메모)</label>
<textarea id="job-spec-remarks" name="remarks" placeholder="기타 필요 사양 및 안내 사항" style="box-sizing: border-box !important; font-size: 13px; margin: 0; min-height: 80px; width: 100%; padding: 8px; border: 1px solid var(--border-color); border-radius: 4px; resize: vertical;"></textarea>
</div>
</form>
</div>
<div class="modal-footer" style="display: flex; justify-content: space-between; align-items: center; padding: 16px 24px; background: #f8fafc; border-top: 1px solid var(--border-color);">
<button id="btn-delete-job-spec-asset" class="btn btn-outline btn-danger" style="height: 42px;">삭제</button>
<div class="footer-actions" style="display: flex; gap: 8px;">
<button id="btn-revert-job-spec-edit" class="btn btn-outline hidden" style="height: 42px;">수정 취소</button>
<button id="btn-cancel-job-spec-modal" class="btn btn-outline" style="height: 42px;">닫기</button>
<button id="btn-save-job-spec-asset" class="btn btn-primary" style="height: 42px;">수정</button>
</div>
</div>
</div>
</div>
`;
}
protected initChildLogic(onSave: () => void, closeModals: () => void): void {
const saveBtn = document.getElementById('btn-save-job-spec-asset')!;
const revertBtn = document.getElementById('btn-revert-job-spec-edit')!;
const deleteBtn = document.getElementById('btn-delete-job-spec-asset')!;
saveBtn.addEventListener('click', async () => {
if (!this.currentAsset) return;
if (!this.isEditMode) {
this.setEditLockMode('edit');
this.isEditMode = true;
return;
}
const jobName = (document.getElementById('job-spec-job-name') as HTMLInputElement).value.trim();
const cpuStd = (document.getElementById('job-spec-cpu-standard') as HTMLInputElement).value.trim();
const ramStd = (document.getElementById('job-spec-ram-standard') as HTMLInputElement).value.trim();
const gpuStd = (document.getElementById('job-spec-gpu-standard') as HTMLInputElement).value.trim();
const minScoreStr = (document.getElementById('job-spec-min-score') as HTMLInputElement).value;
const remarks = (document.getElementById('job-spec-remarks') as HTMLTextAreaElement).value.trim();
if (!jobName) {
alert('직무명을 입력해 주세요.');
return;
}
const updated = {
id: this.currentAsset.id || null,
job_name: jobName,
cpu_standard: cpuStd,
ram_standard: ramStd,
gpu_standard: gpuStd,
min_score: minScoreStr !== '' ? parseInt(minScoreStr, 10) : 0,
remarks: remarks
};
if (await saveJobSpec(updated)) {
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
onSave(); this.close(); closeModals();
}
});
revertBtn.addEventListener('click', () => {
this.setEditLockMode('view');
if (this.currentAsset) this.fillFormData(this.currentAsset);
});
deleteBtn.addEventListener('click', async () => {
if (!this.currentAsset || !this.currentAsset.id) return;
if (!confirm('정말로 이 직무별 기준 사양을 삭제하시겠습니까?')) return;
if (await deleteJobSpec(this.currentAsset.id)) {
alert('성공적으로 삭제되었습니다.');
onSave(); this.close(); closeModals();
}
});
// 자동완성 바인딩
this.bindAutocomplete('job-spec-cpu-standard', 'job-spec-cpu-autocomplete', 'CPU');
this.bindAutocomplete('job-spec-ram-standard', 'job-spec-ram-autocomplete', 'RAM');
this.bindAutocomplete('job-spec-gpu-standard', 'job-spec-gpu-autocomplete', 'GPU');
// 실시간 점수 계산 이벤트 바인딩
const inputs = ['job-spec-cpu-standard', 'job-spec-ram-standard', 'job-spec-gpu-standard'];
inputs.forEach(id => {
const el = document.getElementById(id);
el?.addEventListener('input', () => this.updateMinScore());
el?.addEventListener('change', () => this.updateMinScore());
});
}
private bindAutocomplete(inputId: string, autocompleteId: string, category: string) {
const input = document.getElementById(inputId) as HTMLInputElement;
const list = document.getElementById(autocompleteId) as HTMLDivElement;
if (!input || !list) return;
const showList = (filterText: string = '') => {
if (!this.isEditMode) return;
const items = (state.masterData.partsMaster || []).filter((c: any) => c.category === category);
const filtered = filterText
? items.filter((c: any) => c.component_name.toLowerCase().includes(filterText.toLowerCase()))
: items;
if (filtered.length === 0) {
list.innerHTML = '<div class="autocomplete-item" style="color: #94a3b8; cursor: default;">검색 결과 없음</div>';
} else {
list.innerHTML = filtered.map((c: any) => `<div class="autocomplete-item" data-val="${c.component_name}">${c.component_name}</div>`).join('');
}
list.classList.remove('hidden');
};
input.addEventListener('focus', () => {
showList(input.value);
});
input.addEventListener('input', () => {
showList(input.value);
});
list.addEventListener('mousedown', (e) => {
const item = (e.target as HTMLElement).closest('.autocomplete-item');
if (item && item.getAttribute('data-val')) {
input.value = item.getAttribute('data-val') || '';
list.classList.add('hidden');
this.updateMinScore();
}
});
document.addEventListener('mousedown', (e) => {
if (e.target !== input && !list.contains(e.target as Node)) {
list.classList.add('hidden');
}
});
}
private updateMinScore(): void {
const cpu = (document.getElementById('job-spec-cpu-standard') as HTMLInputElement)?.value || '';
const ram = (document.getElementById('job-spec-ram-standard') as HTMLInputElement)?.value || '';
const gpu = (document.getElementById('job-spec-gpu-standard') as HTMLInputElement)?.value || '';
const score = calculatePcScoreDeductive(cpu, ram, gpu, '');
const minScoreEl = document.getElementById('job-spec-min-score') as HTMLInputElement;
if (minScoreEl) {
minScoreEl.value = score.toString();
}
}
protected fillFormData(asset: any): void {
setFieldValue('job-spec-id', asset.id || '');
setFieldValue('job-spec-job-name', asset.job_name || '');
setFieldValue('job-spec-cpu-standard', asset.cpu_standard || '');
setFieldValue('job-spec-ram-standard', asset.ram_standard || '');
setFieldValue('job-spec-gpu-standard', asset.gpu_standard || '');
setFieldValue('job-spec-min-score', asset.min_score !== undefined ? asset.min_score.toString() : '100');
setFieldValue('job-spec-remarks', asset.remarks || '');
}
protected onAfterOpen(asset: any, mode: string): void {
const titleEl = document.getElementById('job-spec-modal-title');
if (titleEl) {
if (mode === 'add') {
titleEl.textContent = '신규 직무별 기준 사양 등록';
} else {
titleEl.textContent = '직무별 기준 사양 상세 편집';
}
}
const deleteBtn = document.getElementById('btn-delete-job-spec-asset')!;
const saveBtn = document.getElementById('btn-save-job-spec-asset')!;
deleteBtn.style.display = (mode === 'add') ? 'none' : 'block';
if (mode === 'add') {
this.setEditLockMode('edit');
this.isEditMode = true;
saveBtn.textContent = '등록';
saveBtn.style.display = 'block';
} else {
this.setEditLockMode('view');
this.isEditMode = false;
saveBtn.textContent = '수정';
saveBtn.style.display = 'block';
}
this.updateMinScore();
}
}
export const jobSpecModal = new JobSpecModal();
export function initJobSpecModal(onSave: () => void, closeModals: () => void) {
jobSpecModal.init(onSave, closeModals);
}
export function openJobSpecModal(asset: any, mode: 'view' | 'edit' | 'add' = 'view') {
jobSpecModal.open(asset, mode);
}

View File

@@ -100,12 +100,12 @@ class SwAssetModal extends BaseModal {
<div class="form-section-title">관리 및 비고</div> <div class="form-section-title">관리 및 비고</div>
<div class="form-group sw-standard-field"> <div class="form-group sw-standard-field">
<label>${ASSET_SCHEMA.PURCHASE_DATE.ui}</label> <label>${ASSET_SCHEMA.PURCHASE_DATE.ui}</label>
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;"> <div class="input-with-btn">
<input type="text" id="sw-구매일" name="purchase_date" style="flex:1;" /> <input type="text" id="sw-구매일" name="purchase_date" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-구매일-picker'); p.value = document.getElementById('sw-구매일').value; p.showPicker();" style="padding:0.25rem;"> <button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-구매일-picker'); p.value = document.getElementById('sw-구매일').value; p.showPicker();">
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i> <i data-lucide="calendar"></i>
</button> </button>
<input type="date" id="sw-구매일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('sw-구매일').value = this.value" tabindex="-1" /> <input type="date" id="sw-구매일-picker" class="hidden-picker" onchange="document.getElementById('sw-구매일').value = this.value" tabindex="-1" />
</div> </div>
</div> </div>
<div class="form-group sw-standard-field"> <div class="form-group sw-standard-field">
@@ -126,12 +126,12 @@ class SwAssetModal extends BaseModal {
</div> </div>
<div class="form-group sw-standard-field" id="sw-expiry-group"> <div class="form-group sw-standard-field" id="sw-expiry-group">
<label>${ASSET_SCHEMA.EXPIRED_DATE.ui}</label> <label>${ASSET_SCHEMA.EXPIRED_DATE.ui}</label>
<div style="display:flex; gap:0.25rem; align-items:center; position:relative;"> <div class="input-with-btn">
<input type="text" id="sw-만료일" name="expiry_date" style="flex:1;" /> <input type="text" id="sw-만료일" name="expiry_date" />
<button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-만료일-picker'); p.value = document.getElementById('sw-만료일').value; p.showPicker();" style="padding:0.25rem;"> <button type="button" class="btn-icon" onclick="const p = document.getElementById('sw-만료일-picker'); p.value = document.getElementById('sw-만료일').value; p.showPicker();">
<i data-lucide="calendar" style="width:18px; height:18px; color:var(--primary-color);"></i> <i data-lucide="calendar"></i>
</button> </button>
<input type="date" id="sw-만료일-picker" style="position:absolute; width:0; height:0; opacity:0; pointer-events:none;" onchange="document.getElementById('sw-만료일').value = this.value" tabindex="-1" /> <input type="date" id="sw-만료일-picker" class="hidden-picker" onchange="document.getElementById('sw-만료일').value = this.value" tabindex="-1" />
</div> </div>
</div> </div>
<div class="form-group full-width"> <div class="form-group full-width">
@@ -140,18 +140,18 @@ class SwAssetModal extends BaseModal {
</div> </div>
</form> </form>
<div id="sw-user-section" class="user-management-section" style="margin-top: 2rem; border-top: 1px solid var(--border-color); padding-top: 1.5rem;"> <div id="sw-user-section" class="user-management-section">
<button type="button" id="btn-open-sw-user" class="btn btn-outline btn-sm" title="사용자 관리"> <button type="button" id="btn-open-sw-user" class="btn btn-outline btn-sm" title="사용자 관리">
<i data-lucide="users" style="width:16px; height:16px; margin-right:4px;"></i> 사용자 관리 <i data-lucide="users"></i> 사용자 관리
</button> </button>
</div> </div>
</div> </div>
<div class="modal-history-area"> <div class="modal-history-area">
<div class="history-header" style="display:flex; justify-content:space-between; align-items:center;"> <div class="history-header">
<h3><i data-lucide="history" style="width:16px; height:16px;"></i> 업데이트 내역</h3> <h3><i data-lucide="history"></i> 업데이트 내역</h3>
<button type="button" id="btn-open-sw-update" class="btn btn-outline btn-sm"> <button type="button" id="btn-open-sw-update" class="btn btn-outline btn-sm">
계약 업데이트 <i data-lucide="refresh-ccw" style="width:14px; height:14px;"></i> 계약 업데이트 <i data-lucide="refresh-ccw"></i>
</button> </button>
</div> </div>
<div id="sw-history-list" class="history-timeline"></div> <div id="sw-history-list" class="history-timeline"></div>
@@ -173,7 +173,7 @@ class SwAssetModal extends BaseModal {
<div id="sw-update-modal" class="modal-overlay hidden" style="z-index: 1100;"> <div id="sw-update-modal" class="modal-overlay hidden" style="z-index: 1100;">
<div class="modal-content" style="max-width: 500px;"> <div class="modal-content" style="max-width: 500px;">
<div class="modal-header"> <div class="modal-header">
<h2>계약 업데이트 반영</h2> <h2 class="modal-title">계약 업데이트 반영</h2>
<button id="btn-close-sw-update" class="btn-icon"><i data-lucide="x"></i></button> <button id="btn-close-sw-update" class="btn-icon"><i data-lucide="x"></i></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
@@ -184,10 +184,10 @@ class SwAssetModal extends BaseModal {
</div> </div>
<div class="form-group sub-sw-update"> <div class="form-group sub-sw-update">
<label>새로운 계약 기간</label> <label>새로운 계약 기간</label>
<div style="display: flex; align-items: center; gap: 0.5rem;"> <div class="input-with-btn">
<input type="text" id="sw-update-start" placeholder="YYYY-MM-DD" style="flex: 1;" /> <input type="text" id="sw-update-start" placeholder="YYYY-MM-DD" />
<span>~</span> <span>~</span>
<input type="text" id="sw-update-end" placeholder="YYYY-MM-DD" style="flex: 1;" /> <input type="text" id="sw-update-end" placeholder="YYYY-MM-DD" />
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">

View File

@@ -24,93 +24,111 @@ const MENU_CONFIG: any = {
}; };
export function renderNavigation(onTabChange: (tab: string) => void) { export function renderNavigation(onTabChange: (tab: string) => void) {
const navContainer = document.getElementById('main-nav')!; const headerContainer = document.querySelector('.header-container')!;
if (!headerContainer) return;
const render = () => { const render = () => {
navContainer.innerHTML = ''; // 1. 헤더 레이아웃 구조 생성
headerContainer.innerHTML = `
<!-- [TOP ROW] 로고 및 사용자 액션 -->
<div class="header-top-row">
<div class="brand" id="btn-home-logo" style="cursor: pointer;">
<img src="img/image_92.png" class="main-logo" alt="HM Logo" />
<h1>IT 자산 통합 관리 <span class="sub-title">ITAM</span></h1>
</div>
<div class="header-actions">
<div class="role-switcher">
<span class="role-label user ${state.currentUserRole === 'user' ? 'active' : ''}">실무자</span>
<label class="switch">
<input type="checkbox" id="role-toggle-checkbox" ${state.currentUserRole === 'admin' ? 'checked' : ''}>
<span class="slider"></span>
</label>
<span class="role-label admin ${state.currentUserRole === 'admin' ? 'active' : ''}">관리자</span>
</div>
<div class="notification-area">
<button class="icon-btn" title="알림"><i data-lucide="bell" style="width:18px; height:18px;"></i></button>
</div>
</div>
</div>
// 기존 메뉴 렌더링 <!-- [BOTTOM ROW] 통합 내비게이션 (2단 메뉴) -->
<div class="header-bottom-row">
<nav class="integrated-nav" id="main-nav-list"></nav>
</div>
`;
const navList = document.getElementById('main-nav-list')!;
// 2. 메뉴 그룹화 및 렌더링 (대분류 제목 제외, 간격으로 구분)
(Object.keys(MENU_CONFIG) as Array<keyof typeof MENU_CONFIG>).forEach(catKey => { (Object.keys(MENU_CONFIG) as Array<keyof typeof MENU_CONFIG>).forEach(catKey => {
const config = MENU_CONFIG[catKey]; const config = MENU_CONFIG[catKey];
// 역할에 따라 노출할 서브탭 필터링
const visibleTabs = config.tabs.filter((tab: string) => { const visibleTabs = config.tabs.filter((tab: string) => {
if (state.currentUserRole === 'admin') { if (state.currentUserRole === 'admin') return tab === '대시보드';
// 관리자(admin)일 경우 대시보드 탭만 노출 return tab !== '대시보드';
return tab === '대시보드';
} else {
// 실무자(user)일 경우 대시보드 제외한 모든 탭 노출
return tab !== '대시보드';
}
}); });
// 노출할 서브탭이 없으면 해당 대분류 GNB 메뉴도 렌더링하지 않음 if (visibleTabs.length === 0) return;
if (visibleTabs.length === 0) {
return;
}
const isActive = state.activeCategory === catKey;
const group = document.createElement('div'); const group = document.createElement('div');
group.className = `nav-group ${isActive ? 'active is-showing-shelf' : ''}`; group.className = 'nav-group';
const trigger = document.createElement('div'); const itemsContainer = document.createElement('div');
trigger.className = 'gnb-trigger'; itemsContainer.className = 'nav-group-items';
trigger.textContent = config.label;
trigger.addEventListener('click', () => {
if (state.activeCategory !== catKey) {
state.activeCategory = catKey as any;
const firstTab = visibleTabs[0] || config.tabs[0];
state.activeSubTab = firstTab;
render();
onTabChange(firstTab);
}
});
group.appendChild(trigger);
const shelf = document.createElement('div');
shelf.className = 'lnb-shelf';
visibleTabs.forEach((tab: string) => { visibleTabs.forEach((tab: string) => {
if (tab === '부품 마스터') return; // 메뉴바에서 표시 생략 if (tab === '부품 마스터') return;
const item = document.createElement('div'); const item = document.createElement('div');
item.className = `lnb-item ${isActive && state.activeSubTab === tab ? 'active' : ''}`; const isActive = state.activeSubTab === tab;
item.className = `gnb-trigger ${isActive ? 'active' : ''}`;
item.textContent = tab; item.textContent = tab;
item.addEventListener('click', (e) => { item.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();
state.activeCategory = catKey as any; state.activeCategory = catKey as any;
state.activeSubTab = tab; state.activeSubTab = tab;
render(); render(); // 재렌더링하여 활성 상태 반영
onTabChange(tab); onTabChange(tab);
}); });
shelf.appendChild(item); itemsContainer.appendChild(item);
}); });
group.appendChild(shelf);
navContainer.appendChild(group); group.appendChild(itemsContainer);
navList.appendChild(group);
}); });
// ─── '관리자' 메뉴 별도 추가 (GNB 스타일 - 관리자 역할일 때만 노출) ─── // 3. 관리자 전용 '관리도구' (원래 '관리자' 메뉴)
if (state.currentUserRole === 'admin') { if (state.currentUserRole === 'admin') {
const adminGroup = document.createElement('div'); const adminGroup = document.createElement('div');
adminGroup.className = 'nav-group'; adminGroup.className = 'nav-group';
const adminTrigger = document.createElement('div'); const adminTrigger = document.createElement('div');
adminTrigger.className = 'gnb-trigger'; adminTrigger.className = 'gnb-trigger admin-trigger';
adminTrigger.innerHTML = '관리'; adminTrigger.innerHTML = '관리도구';
adminTrigger.style.color = 'var(--text-muted)'; adminTrigger.addEventListener('click', () => window.open('/map_editor.html', '_blank'));
adminTrigger.style.borderLeft = '1px solid var(--border-color)';
adminTrigger.style.marginLeft = '1rem';
adminTrigger.style.paddingLeft = '1.5rem';
adminTrigger.addEventListener('click', () => {
window.open('/map_editor.html', '_blank');
});
adminGroup.appendChild(adminTrigger); adminGroup.appendChild(adminTrigger);
navContainer.appendChild(adminGroup); navList.appendChild(adminGroup);
} }
// 4. 이벤트 바인딩 (로고 클릭 및 역할 전환)
document.getElementById('btn-home-logo')?.addEventListener('click', () => location.reload());
const roleToggle = document.getElementById('role-toggle-checkbox') as HTMLInputElement;
roleToggle?.addEventListener('change', () => {
state.currentUserRole = roleToggle.checked ? 'admin' : 'user';
if (state.currentUserRole === 'admin') {
state.activeCategory = 'hw';
state.activeSubTab = '대시보드';
} else {
state.activeCategory = 'hw';
state.activeSubTab = '서버';
}
render();
onTabChange(state.activeSubTab);
});
// 아이콘 생성
// @ts-ignore
if (window.lucide) window.lucide.createIcons();
}; };
render(); render();

View File

@@ -160,16 +160,6 @@ export const PAGE_DESCRIPTIONS: Record<string, { title: string; description: str
title: '임직원 사용자 관리', title: '임직원 사용자 관리',
description: 'IT 자산 할당 및 관리의 기준이 되는 사내 임직원(사용자) 정보를 데이터베이스 기반으로 직접 등록하고 수정합니다.', description: 'IT 자산 할당 및 관리의 기준이 되는 사내 임직원(사용자) 정보를 데이터베이스 기반으로 직접 등록하고 수정합니다.',
icon: 'users' icon: 'users'
},
'부품 마스터': {
title: '부품 표준 정보 관리',
description: 'PC 사양 적정성 평가의 기준이 되는 부품 표준 정보 및 등급별 감점 점수를 관리합니다.',
icon: 'cpu'
},
'직무별 기준 사양': {
title: '직무별 기준 사양 관리',
description: 'BIM 모델러, 개발자, 엔지니어 등 사내 직무별 권장 하드웨어 기준 및 성능 합격 점수를 관리합니다.',
icon: 'sliders'
} }
}; };

View File

@@ -22,7 +22,6 @@ export interface MasterAssetData {
vip: any[]; vip: any[];
mobile?: any[]; // Legacy mobile support mobile?: any[]; // Legacy mobile support
equip?: any[]; // Backward compat equip?: any[]; // Backward compat
jobSpecs?: any[];
// Backward compatibility // Backward compatibility
subSw: any[]; subSw: any[];
@@ -62,8 +61,7 @@ export const state: AppState = {
cost: [], vip: [], cost: [], vip: [],
subSw: [], permSw: [], subSw: [], permSw: [],
hw: [], sw: [], hw: [], sw: [],
swUsers: [], logs: [], swUsers: [], logs: []
jobSpecs: []
} }
}; };
@@ -81,7 +79,6 @@ export async function loadMasterDataFromDB() {
state.masterData = { state.masterData = {
...state.masterData, ...state.masterData,
...data, ...data,
jobSpecs: data.jobSpecs || [],
logs: (data.logs || []).map((l: any) => ({ logs: (data.logs || []).map((l: any) => ({
...l, ...l,
assetId: l.asset_id || l.assetId, assetId: l.asset_id || l.assetId,
@@ -232,38 +229,3 @@ export async function deleteSystemUser(id: string) {
} }
return false; return false;
} }
export async function saveJobSpec(spec: any) {
try {
const url = `${API_BASE_URL}/api/job-specs/save`;
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(spec)
});
if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신
return true;
}
} catch (err) {
console.error('직무별 기준 사양 저장 실패:', err);
}
return false;
}
export async function deleteJobSpec(id: number) {
try {
const url = `${API_BASE_URL}/api/job-specs/${id}`;
const response = await fetch(url, { method: 'DELETE' });
if (response.ok) {
await loadMasterDataFromDB(); // 전역 상태 갱신
return true;
}
} catch (err) {
console.error('직무별 기준 사양 삭제 실패:', err);
}
return false;
}

View File

@@ -288,12 +288,11 @@ export function calculatePcScoreDeductive(cpu: string, ram: string, gpu: string,
/** /**
* 성능 점수 기준 등급 뱃지 메타 정보 가져오기 * 성능 점수 기준 등급 뱃지 메타 정보 가져오기
*/ */
export function getPcGrade(score: number, isWin11Incompatible?: boolean): { name: string; class: string; color: string } { export function getPcGrade(score: number): { name: string; class: string; color: string } {
if (score >= 85) return { name: '최상급', class: 'b-purple', color: '#7C3AED' }; if (score >= 85) return { name: '최상급', class: 'b-purple', color: '#7C3AED' };
if (score >= 70) return { name: '상급', class: 'b-primary', color: '#4F46E5' }; if (score >= 70) return { name: '상급', class: 'b-primary', color: '#4F46E5' };
if (score >= 40) return { name: '중급', class: 'b-green', color: '#10B981' }; if (score >= 40) return { name: '중급', class: 'b-green', color: '#10B981' };
if (score >= 20 && !isWin11Incompatible) return { name: '보급', class: 'b-yellow', color: '#F59E0B' }; return { name: '보급', class: 'b-yellow', color: '#F59E0B' };
return { name: '교체 대상', class: 'badge-danger', color: '#EF4444' };
} }
/** /**

View File

@@ -9,9 +9,7 @@ import { initSwModal, openSwModal } from './components/Modal/SWModal';
import { initSwUserModal } from './components/Modal/SWUserModal'; import { initSwUserModal } from './components/Modal/SWUserModal';
import { initDomainModal, openDomainModal } from './components/Modal/DomainModal'; import { initDomainModal, openDomainModal } from './components/Modal/DomainModal';
import { initPartsMasterModal, openPartsMasterModal } from './components/Modal/PartsMasterModal'; import { initPartsMasterModal, openPartsMasterModal } from './components/Modal/PartsMasterModal';
import { initJobSpecModal, openJobSpecModal } from './components/Modal/JobSpecModal';
import { initUserModal, openUserModal } from './components/Modal/UserModal'; import { initUserModal, openUserModal } from './components/Modal/UserModal';
import { activePartsMasterSubTab } from './views/List/PartsMasterListView';
import { initDashboardDetailModal } from './components/Modal/DashboardDetailModal'; import { initDashboardDetailModal } from './components/Modal/DashboardDetailModal';
import { initGuide } from './components/Guide'; import { initGuide } from './components/Guide';
import { pcFlowModal } from './components/Modal/PCFlowModal'; import { pcFlowModal } from './components/Modal/PCFlowModal';
@@ -19,21 +17,23 @@ import { createIcons, Plus, X, LayoutDashboard, Monitor, Server, Database, Lapto
// 화면 갱신 통합 핸들러 // 화면 갱신 통합 핸들러
function refreshView() { function refreshView(tab?: string) {
const mainContent = document.getElementById('main-content')!; const mainContent = document.getElementById('main-content')!;
if (!mainContent) return; if (!mainContent) return;
if (state.activeSubTab === '대시보드') { const activeTab = tab || state.activeSubTab;
if (activeTab === '대시보드') {
renderDashboard(mainContent); renderDashboard(mainContent);
return; return;
} }
// 서버 탭이 아닐 경우 '자산현황(위치)' 뷰 진입 방지 및 강제 리스트 모드 전환 // 서버 탭이 아닐 경우 '자산현황(위치)' 뷰 진입 방지 및 강제 리스트 모드 전환
if (state.activeSubTab !== '서버' && state.viewMode === 'location') { if (activeTab !== '서버' && state.viewMode === 'location') {
state.viewMode = 'list'; state.viewMode = 'list';
} }
const isServerTab = state.activeSubTab === '서버'; const isServerTab = activeTab === '서버';
mainContent.innerHTML = ` mainContent.innerHTML = `
<div class="view-header"> <div class="view-header">
@@ -87,7 +87,6 @@ function initApp() {
}, closeAllModals); }, closeAllModals);
initDomainModal(() => refreshAllData(), closeAllModals); initDomainModal(() => refreshAllData(), closeAllModals);
initPartsMasterModal(() => refreshAllData(), closeAllModals); initPartsMasterModal(() => refreshAllData(), closeAllModals);
initJobSpecModal(() => refreshAllData(), closeAllModals);
initUserModal(() => refreshAllData(), closeAllModals); initUserModal(() => refreshAllData(), closeAllModals);
initDashboardDetailModal(); initDashboardDetailModal();
@@ -117,11 +116,7 @@ function initApp() {
if (cat === 'hw') { if (cat === 'hw') {
if (tab === '부품 마스터') { if (tab === '부품 마스터') {
if (activePartsMasterSubTab === 'job-spec') { openPartsMasterModal({ id: '' } as any, 'add');
openJobSpecModal({ id: '' } as any, 'add');
} else {
openPartsMasterModal({ id: '' } as any, 'add');
}
} else { } else {
openHwModal({ id: newId, asset_code: '', category: tab } as any, 'add'); openHwModal({ id: newId, asset_code: '', category: tab } as any, 'add');
} }
@@ -213,35 +208,19 @@ function initRoleSwitcher() {
function initializeAppDirectly() { function initializeAppDirectly() {
const loginContainer = document.getElementById('login-container'); const loginContainer = document.getElementById('login-container');
const appLayout = document.getElementById('app-layout'); const appLayout = document.getElementById('app-layout');
const checkbox = document.getElementById('role-toggle-checkbox') as HTMLInputElement;
const userLabel = document.querySelector('.role-label.user');
const adminLabel = document.querySelector('.role-label.admin');
// 기본 권한 설정: 실무자 (User) // 기본 권한 설정: 실무자 (User)
state.currentUserRole = 'user'; state.currentUserRole = 'user';
state.activeCategory = 'hw'; state.activeCategory = 'hw';
state.activeSubTab = '서버'; // 실무자 기본 탭 state.activeSubTab = '서버'; // 실무자 기본 탭
// UI 상태 동기화
if (checkbox) checkbox.checked = false;
if (userLabel) userLabel.classList.add('active');
if (adminLabel) adminLabel.classList.remove('active');
document.body.classList.remove('admin-mode');
// 화면 전환 // 화면 전환
if (loginContainer) loginContainer.style.display = 'none'; if (loginContainer) loginContainer.style.display = 'none';
if (appLayout) appLayout.style.display = 'flex'; if (appLayout) appLayout.style.display = 'flex';
// 앱 초기화 // 앱 초기화 및 내비게이션(헤더 포함) 렌더링
initRoleSwitcher();
initApp(); initApp();
renderNavigation((tab) => refreshView(tab));
// 로고 클릭 시 새로고침 (초기 화면 복귀 효과)
const brand = document.querySelector('.brand') as HTMLElement;
if (brand) {
brand.style.cursor = 'pointer';
brand.onclick = () => location.reload();
}
} }
document.addEventListener('DOMContentLoaded', initializeAppDirectly); document.addEventListener('DOMContentLoaded', initializeAppDirectly);

View File

@@ -45,7 +45,7 @@
--white: #FFFFFF; --white: #FFFFFF;
--danger: var(--color-red); --danger: var(--color-red);
--success: var(--color-green); --success: var(--color-green);
--header-height: 52px; --header-height: auto;
} }
* { * {
@@ -60,7 +60,7 @@ body {
color: var(--text-main); color: var(--text-main);
background-color: var(--bg-color); background-color: var(--bg-color);
line-height: 1.5; line-height: 1.5;
font-size: 14px; font-size: 19px;
overflow: hidden; overflow: hidden;
} }
@@ -76,45 +76,50 @@ body {
background-color: var(--white); background-color: var(--white);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
z-index: 100; z-index: 100;
height: var(--header-height); height: auto;
flex-shrink: 0; flex-shrink: 0;
} }
.header-container { .header-container {
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column;
padding: 0;
}
.header-top-row {
height: 52px;
display: flex;
align-items: center; align-items: center;
padding: 0 1.5rem; padding: 0 1.5rem;
gap: 1.5rem; justify-content: space-between;
border-bottom: 1px solid #f3f4f6;
}
.header-bottom-row {
height: 48px;
display: flex;
align-items: center;
padding: 0 1.5rem;
background-color: var(--bg-light);
border-bottom: 1px solid var(--border-color);
} }
.brand { display: flex; align-items: center; gap: 0.75rem; } .brand { display: flex; align-items: center; gap: 0.75rem; }
.main-logo { height: 34px; width: auto; } .main-logo { height: 34px; width: auto; }
.brand h1 { font-size: 1.1rem; font-weight: 800; color: var(--text-main); white-space: nowrap; } .brand h1 { font-size: 1.47rem; font-weight: 900; color: var(--text-main); white-space: nowrap; }
.brand h1 .sub-title { font-size: 0.85rem; color: var(--primary-color); font-weight: 600; margin-left: 0.25rem; } .brand h1 .sub-title { font-size: 1.13rem; color: var(--primary-color); font-weight: 700; margin-left: 0.25rem; }
.integrated-nav { flex: 1; height: 100%; display: flex; align-items: center; gap: 0.25rem; overflow: hidden; } .integrated-nav { flex: 1; height: 100%; display: flex; align-items: center; gap: 2.5rem; overflow: hidden; }
.nav-group { display: flex; align-items: center; height: 100%; position: relative; flex-shrink: 0; } .nav-group { display: flex; align-items: center; height: 100%; position: relative; flex-shrink: 0; }
.gnb-trigger { font-size: 14px; font-weight: 700; color: var(--text-muted); padding: 0 0.75rem; cursor: pointer; height: 100%; display: flex; align-items: center; white-space: nowrap; transition: color 0.2s; } .nav-group-items { display: flex; align-items: center; gap: 0.25rem; height: 100%; }
.nav-group.active .gnb-trigger, .nav-group:hover .gnb-trigger { color: var(--text-main); } .gnb-trigger { font-size: 17px; font-weight: 800; color: var(--text-muted); padding: 0 0.75rem; cursor: pointer; height: 100%; display: flex; align-items: center; white-space: nowrap; transition: color 0.2s; border-bottom: 3px solid transparent; }
.lnb-shelf { display: none; align-items: center; gap: 0.2rem; padding: 0 0.5rem; height: 60%; border-left: 1px solid var(--border-color); margin-left: 0.2rem; } .gnb-trigger:hover { color: var(--text-main); }
.gnb-trigger.active { color: var(--primary-color); font-weight: 900; border-bottom-color: var(--primary-color); background-color: var(--primary-lv-0); }
/* 기본적으로 활성 탭의 서브메뉴 표시 */
.nav-group.active.is-showing-shelf .lnb-shelf { display: flex; }
/* GNB 전체 영역에 마우스가 올라가면 활성 탭의 서브메뉴를 일단 숨김 (다른 메뉴 탐색 우선) */
.integrated-nav:hover .nav-group.active.is-showing-shelf .lnb-shelf { display: none; }
/* 마우스가 올라간 메뉴의 서브메뉴만 표시 */
.nav-group:hover .lnb-shelf { display: flex !important; }
.lnb-item { font-size: 13px; font-weight: 500; color: var(--text-muted); cursor: pointer; padding: 0.2rem 0.6rem; border-radius: 4px; white-space: nowrap; transition: all 0.2s; }
.lnb-item:hover { color: var(--primary-color); background-color: var(--primary-light); }
.lnb-item.active { color: var(--primary-color); background-color: var(--primary-light); font-weight: 700; }
.header-actions { display: flex; align-items: center; gap: 1rem; } .header-actions { display: flex; align-items: center; gap: 1rem; }
.role-switcher { display: flex; align-items: center; gap: 0.75rem; padding: 0 0.75rem; border-right: 1px solid var(--border-color); height: 24px; } .role-switcher { display: flex; align-items: center; gap: 0.75rem; padding: 0 0.75rem; border-right: 1px solid var(--border-color); height: 24px; }
.role-label { font-size: 11px; font-weight: 700; color: var(--text-muted); } .role-label { font-size: 15px; font-weight: 800; color: var(--text-muted); }
.role-label.active { color: var(--primary-color); } .role-label.active { color: var(--primary-color); }
.switch { position: relative; display: inline-block; width: 34px; height: 18px; } .switch { position: relative; display: inline-block; width: 34px; height: 18px; }
.switch input { opacity: 0; width: 0; height: 0; } .switch input { opacity: 0; width: 0; height: 0; }
@@ -149,12 +154,12 @@ input:checked + .slider:before { transform: translateX(16px); }
/* --- View Toggle --- */ /* --- View Toggle --- */
.view-toggle-container { margin-bottom: 1rem; display: flex; justify-content: flex-start; } .view-toggle-container { margin-bottom: 1rem; display: flex; justify-content: flex-start; }
.view-toggle { display: inline-flex; background-color: var(--primary-lv-0); padding: 4px; border-radius: 8px; border: 1px solid var(--border-color); } .view-toggle { display: inline-flex; background-color: var(--primary-lv-0); padding: 4px; border-radius: 8px; border: 1px solid var(--border-color); }
.toggle-btn { padding: 6px 16px; font-size: 13px; font-weight: 600; color: var(--text-muted); background: none; border: none; border-radius: 6px; cursor: pointer; } .toggle-btn { padding: 6px 16px; font-size: 17px; font-weight: 700; color: var(--text-muted); background: none; border: none; border-radius: 6px; cursor: pointer; }
.toggle-btn.active { background-color: var(--white); color: var(--primary-color); box-shadow: 0 2px 4px rgba(0,0,0,0.05); } .toggle-btn.active { background-color: var(--white); color: var(--primary-color); box-shadow: 0 2px 4px rgba(0,0,0,0.05); }
/* --- System Status List (Docker Style) --- */ /* --- System Status List (Docker Style) --- */
.system-status-list { display: flex; flex-direction: column; gap: 0.5rem; } .system-status-list { display: flex; flex-direction: column; gap: 0.5rem; }
.system-list-header { display: flex; align-items: center; padding: 0.75rem 1.25rem; background-color: var(--bg-light); border-bottom: 1px solid var(--border-color); font-size: 11px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; } .system-list-header { display: flex; align-items: center; padding: 0.75rem 1.25rem; background-color: var(--bg-light); border-bottom: 1px solid var(--border-color); font-size: 15px; font-weight: 800; color: var(--text-muted); text-transform: uppercase; }
.system-row { display: flex; align-items: center; padding: 1rem 1.25rem; background-color: var(--white); border: 1px solid var(--border-color); border-radius: 6px; transition: all 0.2s; } .system-row { display: flex; align-items: center; padding: 1rem 1.25rem; background-color: var(--white); border: 1px solid var(--border-color); border-radius: 6px; transition: all 0.2s; }
.system-row:hover { border-color: var(--primary-lv-3); box-shadow: 0 4px 12px rgba(0,0,0,0.03); } .system-row:hover { border-color: var(--primary-lv-3); box-shadow: 0 4px 12px rgba(0,0,0,0.03); }
.col-status { width: 100px; display: flex; align-items: center; gap: 0.5rem; } .col-status { width: 100px; display: flex; align-items: center; gap: 0.5rem; }
@@ -165,12 +170,12 @@ input:checked + .slider:before { transform: translateX(16px); }
.col-actions { width: 120px; display: flex; justify-content: flex-end; } .col-actions { width: 120px; display: flex; justify-content: flex-end; }
.status-dot { width: 10px; height: 10px; border-radius: 50%; } .status-dot { width: 10px; height: 10px; border-radius: 50%; }
.status-dot.online { background-color: var(--success); box-shadow: 0 0 6px var(--success); } .status-dot.online { background-color: var(--success); box-shadow: 0 0 6px var(--success); }
.status-text { font-size: 11px; font-weight: 600; color: var(--success); } .status-text { font-size: 15px; font-weight: 700; color: var(--success); }
.asset-primary { font-weight: 700; font-size: 14px; } .asset-primary { font-weight: 800; font-size: 19px; }
.asset-secondary { font-size: 12px; color: var(--text-muted); } .asset-secondary { font-size: 16px; color: var(--text-muted); }
.ip-address { font-weight: 600; font-family: monospace; color: var(--primary-color); } .ip-address { font-weight: 700; font-family: monospace; color: var(--primary-color); }
.traffic-mini-chart { display: flex; flex-direction: column; gap: 4px; } .traffic-mini-chart { display: flex; flex-direction: column; gap: 4px; }
.traffic-info { display: flex; justify-content: space-between; font-size: 11px; } .traffic-info { display: flex; justify-content: space-between; font-size: 15px; }
.progress-bg { height: 4px; background: var(--primary-lv-0); border-radius: 2px; overflow: hidden; } .progress-bg { height: 4px; background: var(--primary-lv-0); border-radius: 2px; overflow: hidden; }
.progress-fill { height: 100%; background: var(--primary-color); } .progress-fill { height: 100%; background: var(--primary-color); }
.icon-btn { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 4px; border: 1px solid var(--border-color); background: var(--white); color: var(--text-muted); cursor: pointer; } .icon-btn { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; border-radius: 4px; border: 1px solid var(--border-color); background: var(--white); color: var(--text-muted); cursor: pointer; }
@@ -190,8 +195,8 @@ input:checked + .slider:before { transform: translateX(16px); }
.main-footer p { .main-footer p {
font-family: 'Pretendard Variable', Pretendard, sans-serif; font-family: 'Pretendard Variable', Pretendard, sans-serif;
font-size: 0.75rem; font-size: 1rem;
font-weight: 300; font-weight: 400;
line-height: 1.25rem; line-height: 1.25rem;
letter-spacing: -0.0175rem; letter-spacing: -0.0175rem;
color: #777777; color: #777777;
@@ -212,15 +217,15 @@ input:checked + .slider:before { transform: translateX(16px); }
} }
/* --- Utility Styles --- */ /* --- Utility Styles --- */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem; padding: 0 0.8rem; font-size: 12px; font-weight: 600; border-radius: 4px; cursor: pointer; height: 28px; } .btn { display: inline-flex; align-items: center; justify-content: center; gap: 0.35rem; padding: 0 0.8rem; font-size: 16px; font-weight: 700; border-radius: 4px; cursor: pointer; height: 28px; }
.btn-primary { background-color: var(--primary-color); color: var(--white); border: none; } .btn-primary { background-color: var(--primary-color); color: var(--white); border: none; }
.btn-outline { background-color: transparent; color: var(--text-muted); border: 1px solid var(--border-color); } .btn-outline { background-color: transparent; color: var(--text-muted); border: 1px solid var(--border-color); }
.badge { .badge {
padding: 2px 6px; padding: 2px 6px;
border-radius: 4px; border-radius: 4px;
font-size: 16px; font-size: 21px;
font-weight: 700; font-weight: 800;
white-space: nowrap; white-space: nowrap;
} }
@@ -245,34 +250,34 @@ input:checked + .slider:before { transform: translateX(16px); }
background-color: #EDE9FE; background-color: #EDE9FE;
color: #7C3AED; color: #7C3AED;
border: 1px solid #DDD6FE; border: 1px solid #DDD6FE;
font-size: 11px; font-size: 15px;
padding: 2px 6px; padding: 2px 6px;
} }
.badge.b-primary { .badge.b-primary {
background-color: #DBEAFE; background-color: #DBEAFE;
color: #1D4ED8; color: #1D4ED8;
border: 1px solid #BFDBFE; border: 1px solid #BFDBFE;
font-size: 11px; font-size: 15px;
padding: 2px 6px; padding: 2px 6px;
} }
.badge.b-green { .badge.b-green {
background-color: #D1FAE5; background-color: #D1FAE5;
color: #047857; color: #047857;
border: 1px solid #A7F3D0; border: 1px solid #A7F3D0;
font-size: 11px; font-size: 15px;
padding: 2px 6px; padding: 2px 6px;
} }
.badge.b-yellow { .badge.b-yellow {
background-color: #FEF3C7; background-color: #FEF3C7;
color: #D97706; color: #D97706;
border: 1px solid #FDE68A; border: 1px solid #FDE68A;
font-size: 11px; font-size: 15px;
padding: 2px 6px; padding: 2px 6px;
} }
.text-tag { .text-tag {
color: var(--text-muted); color: var(--text-muted);
font-size: 16px; font-size: 21px;
padding: 1px 5px; padding: 1px 5px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 3px; border-radius: 3px;
@@ -280,14 +285,14 @@ input:checked + .slider:before { transform: translateX(16px); }
} }
.font-bold { .font-bold {
font-weight: 700; font-weight: 800;
} }
/* --- Responsive Design (Tablet & Mobile) --- */ /* --- Responsive Design (Tablet & Mobile) --- */
@media (max-width: 1200px) { @media (max-width: 1200px) {
.header-container { gap: 0.75rem; padding: 0 1rem; } .header-container { gap: 0.75rem; padding: 0 1rem; }
.brand h1 { font-size: 1rem; } .brand h1 { font-size: 1.33rem; }
.brand h1 .sub-title { font-size: 0.75rem; } .brand h1 .sub-title { font-size: 1rem; }
} }
@media (max-width: 992px) { @media (max-width: 992px) {
@@ -298,7 +303,9 @@ input:checked + .slider:before { transform: translateX(16px); }
.content-area { padding: 0 1rem; } .content-area { padding: 0 1rem; }
} }
@media (max-width: 768px) { .gnb-trigger.admin-trigger {
.brand h1 .sub-title { display: none; } color: var(--text-muted);
.header-actions .btn span { display: none; } border-left: 1px solid var(--border-color);
margin-left: 1rem;
padding-left: 1.5rem;
} }

View File

@@ -1,8 +1,8 @@
/* --- Premium Executive Dashboard View Specific Styles --- */ /* --- Premium Executive Dashboard View Specific Styles --- */
.dashboard-section-title { .dashboard-section-title {
padding: 0 0 0 8px; padding: 0 0 0 8px;
font-size: 1.55rem; font-size: 2.06rem;
font-weight: 800; font-weight: 900;
color: var(--text-main); color: var(--text-main);
letter-spacing: -0.02em; letter-spacing: -0.02em;
border-left: 4px solid var(--primary-color); border-left: 4px solid var(--primary-color);
@@ -54,16 +54,17 @@
min-height: 380px; min-height: 380px;
} }
.dashboard-card canvas { .dashboard-stats-grid {
flex: 1; display: grid;
width: 100% !important; grid-template-columns: repeat(3, 1fr);
max-height: 280px; gap: 1.5rem;
margin-bottom: 2rem;
} }
/* Premium KPI Value Styling */ /* Premium KPI Value Styling */
.stat-value { .stat-value {
font-size: 2.41rem; font-size: 3.21rem;
font-weight: 800; font-weight: 900;
background: linear-gradient(135deg, #1E5149 0%, #3B82F6 100%); background: linear-gradient(135deg, #1E5149 0%, #3B82F6 100%);
-webkit-background-clip: text; -webkit-background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
@@ -80,9 +81,9 @@
} }
.stat-label { .stat-label {
font-size: 1.36rem; font-size: 1.81rem;
color: var(--text-muted); color: var(--text-muted);
font-weight: 700; font-weight: 800;
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
@@ -117,10 +118,10 @@
.table-premium th { .table-premium th {
background: #F8FAFC; background: #F8FAFC;
color: #475569; color: #475569;
font-weight: 700; font-weight: 800;
padding: 1rem; padding: 1rem;
text-transform: uppercase; text-transform: uppercase;
font-size: 0.96rem; font-size: 1.28rem;
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
@@ -128,7 +129,7 @@
padding: 1rem; padding: 1rem;
border-bottom: 1px solid #E2E8F0; border-bottom: 1px solid #E2E8F0;
color: #1E293B; color: #1E293B;
font-size: 16px; font-size: 21px;
} }
.table-premium tr:hover td { .table-premium tr:hover td {
@@ -176,9 +177,9 @@
} }
.page-info { .page-info {
font-size: 0.96rem; font-size: 1.28rem;
color: var(--text-muted); color: var(--text-muted);
font-weight: 600; font-weight: 700;
} }
.page-btns button { .page-btns button {
@@ -186,7 +187,7 @@
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
background: var(--white); background: var(--white);
border-radius: 4px; border-radius: 4px;
font-size: 0.96rem; font-size: 1.28rem;
cursor: pointer; cursor: pointer;
} }
@@ -196,9 +197,9 @@
} }
.slider-indicator { .slider-indicator {
font-weight: 700; font-weight: 800;
color: var(--text-muted); color: var(--text-muted);
font-size: 1.41rem; font-size: 1.88rem;
} }
.dashboard-slider-viewport { .dashboard-slider-viewport {
@@ -238,8 +239,8 @@
} }
.section-title { .section-title {
font-size: 1.125rem; font-size: 1.5rem;
font-weight: 700; font-weight: 800;
margin-bottom: 1rem; margin-bottom: 1rem;
color: var(--text-main); color: var(--text-main);
display: flex; display: flex;
@@ -277,8 +278,8 @@
display: inline-block; display: inline-block;
padding: 0.25rem 0.625rem; padding: 0.25rem 0.625rem;
border-radius: 9999px; border-radius: 9999px;
font-size: 0.75rem; font-size: 1rem;
font-weight: 600; font-weight: 700;
background: #ecfdf5; background: #ecfdf5;
color: #059669; color: #059669;
border: 1px solid #d1fae5; border: 1px solid #d1fae5;
@@ -317,8 +318,8 @@
border: none; border: none;
background: transparent; background: transparent;
border-radius: 6px; border-radius: 6px;
font-size: 0.8125rem; font-size: 1.08rem;
font-weight: 600; font-weight: 700;
color: var(--text-muted); color: var(--text-muted);
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
@@ -357,8 +358,8 @@
} }
.filter-group label { .filter-group label {
font-size: 0.8125rem; font-size: 1.08rem;
font-weight: 700; font-weight: 800;
color: var(--text-main); color: var(--text-main);
} }
@@ -366,7 +367,7 @@
padding: 0.4rem 0.75rem; padding: 0.4rem 0.75rem;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 6px; border-radius: 6px;
font-size: 0.8125rem; font-size: 1.08rem;
color: var(--text-main); color: var(--text-main);
background: var(--white); background: var(--white);
min-width: 140px; min-width: 140px;
@@ -402,8 +403,8 @@
} }
.box-label-text { .box-label-text {
font-size: 0.65rem; font-size: 0.87rem;
font-weight: 800; font-weight: 900;
color: var(--primary-color); color: var(--primary-color);
pointer-events: none; pointer-events: none;
text-shadow: 0 0 2px white; text-shadow: 0 0 2px white;
@@ -426,7 +427,7 @@
.asset-list-section h4 { .asset-list-section h4 {
margin: 0; margin: 0;
font-size: 0.9375rem; font-size: 1.25rem;
color: var(--text-main); color: var(--text-main);
} }
@@ -446,15 +447,15 @@
background: var(--white); background: var(--white);
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
text-align: left; text-align: left;
font-size: 0.75rem; font-size: 1rem;
font-weight: 700; font-weight: 800;
color: var(--text-muted); color: var(--text-muted);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
} }
.compact-table td { .compact-table td {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
font-size: 0.8125rem; font-size: 1.08rem;
border-bottom: 1px solid #f1f5f9; border-bottom: 1px solid #f1f5f9;
white-space: nowrap; white-space: nowrap;
overflow: hidden; overflow: hidden;
@@ -481,8 +482,8 @@
} }
.detail-section-title { .detail-section-title {
font-size: 13px; font-size: 17px;
font-weight: 700; font-weight: 800;
color: var(--primary-color); color: var(--primary-color);
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
padding-bottom: 6px; padding-bottom: 6px;
@@ -496,17 +497,17 @@
} }
.detail-label { .detail-label {
font-size: 12px; font-size: 16px;
color: var(--text-muted); color: var(--text-muted);
font-weight: 600; font-weight: 700;
display: flex; display: flex;
align-items: center; align-items: center;
} }
.detail-value { .detail-value {
font-size: 14px; font-size: 19px;
color: var(--text-main); color: var(--text-main);
font-weight: 500; font-weight: 600;
word-break: break-all; word-break: break-all;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -521,6 +522,134 @@
.detail-header-title { .detail-header-title {
flex: 1; flex: 1;
font-size: 0.95rem; font-size: 1.27rem;
font-weight: 700; font-weight: 800;
} }
/* --- System Dashboard Stats Row (ListFactory) --- */
.dashboard-stats-row {
display: grid;
grid-template-columns: 1fr 1.5fr 1.5fr;
gap: 2rem;
border-bottom: 1px solid var(--border-color);
padding-bottom: 1.25rem;
margin-bottom: 1rem;
flex-shrink: 0;
}
.stat-group-item {
min-width: 0;
display: flex;
flex-direction: column;
}
.stat-group-item.bordered {
border-left: 1px solid var(--border-color);
padding-left: 1.5rem;
}
.stat-group-item .stat-label {
font-size: 15px;
font-weight: 600;
color: var(--text-muted);
margin-bottom: 0.25rem;
letter-spacing: 0;
}
.stat-group-item .stat-value {
font-size: 37px;
font-weight: 900;
color: var(--text-main);
line-height: 1.1;
background: none;
-webkit-text-fill-color: initial;
}
.stat-group-item .stat-value span {
font-size: 17px;
font-weight: 700;
margin-left: 4px;
color: var(--text-muted);
}
.stat-group-item .stat-sub {
display: flex;
gap: 1rem;
font-size: 19px;
color: var(--text-muted);
margin-top: 0.5rem;
}
.stat-group-item .stat-sub strong {
font-size: 24px;
}
.text-primary {
color: var(--primary-color) !important;
}
.detail-stat-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
gap: 0.5rem;
}
.stat-title {
font-size: 19px;
font-weight: 900;
color: var(--text-main);
white-space: nowrap;
}
.stat-badges {
display: flex;
gap: 4px;
flex-wrap: wrap;
justify-content: flex-end;
}
.detail-stat-body {
display: flex;
flex-direction: column;
gap: 0.4rem;
font-size: 17px;
color: var(--text-muted);
}
.loc-summary {
display: flex;
gap: 1rem;
flex-wrap: wrap;
}
.loc-summary span strong {
color: var(--text-main);
font-size: 19px;
}
.type-summary {
display: flex;
gap: 0.8rem;
flex-wrap: wrap;
opacity: 0.9;
border-top: 1px dashed var(--border-color);
padding-top: 6px;
margin-top: 4px;
}
.type-summary span {
cursor: help;
}
.type-summary span strong {
color: var(--text-main);
font-size: 19px;
}
.text-danger {
color: var(--danger) !important;
font-weight: 800;
}

View File

@@ -19,8 +19,8 @@
.guide-tab { .guide-tab {
padding: 0.75rem 1.25rem; padding: 0.75rem 1.25rem;
font-size: 18px; font-size: 24px;
font-weight: 600; font-weight: 700;
color: var(--text-muted); color: var(--text-muted);
cursor: pointer; cursor: pointer;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
@@ -72,7 +72,7 @@
} }
.guide-section h3 { .guide-section h3 {
font-size: 1.3rem; font-size: 1.73rem;
padding-bottom: 0.5rem; padding-bottom: 0.5rem;
border-bottom: 2px solid var(--primary-color); border-bottom: 2px solid var(--primary-color);
color: var(--primary-color); color: var(--primary-color);
@@ -83,7 +83,7 @@
} }
.guide-text { .guide-text {
font-size: 18px; font-size: 24px;
color: var(--text-main); color: var(--text-main);
line-height: 1.7; line-height: 1.7;
margin: 0; margin: 0;
@@ -127,8 +127,8 @@
border-radius: 50%; border-radius: 50%;
background-color: var(--primary-color); background-color: var(--primary-color);
color: white; color: white;
font-size: 17px; font-size: 23px;
font-weight: 700; font-weight: 800;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@@ -136,14 +136,14 @@
} }
.flow-step .step-label { .flow-step .step-label {
font-weight: 700; font-weight: 800;
color: var(--text-main); color: var(--text-main);
font-size: 18px; font-size: 24px;
display: block; display: block;
} }
.flow-step .step-desc { .flow-step .step-desc {
font-size: 17px; font-size: 23px;
color: var(--text-muted); color: var(--text-muted);
line-height: 1.5; line-height: 1.5;
margin-top: 4px; margin-top: 4px;
@@ -159,13 +159,13 @@
.guide-info-table { .guide-info-table {
width: 100%; width: 100%;
border-collapse: collapse; border-collapse: collapse;
font-size: 18px; font-size: 24px;
} }
.guide-info-table th { .guide-info-table th {
background: #f8faf9; background: #f8faf9;
color: var(--primary-color); color: var(--primary-color);
font-weight: 700; font-weight: 800;
padding: 0.75rem; padding: 0.75rem;
text-align: left; text-align: left;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
@@ -182,7 +182,7 @@
background: var(--primary-light); background: var(--primary-light);
border-left: 4px solid var(--primary-color); border-left: 4px solid var(--primary-color);
padding: 1rem; padding: 1rem;
font-size: 18px; font-size: 24px;
color: var(--primary-color); color: var(--primary-color);
line-height: 1.6; line-height: 1.6;
} }

View File

@@ -36,14 +36,14 @@
} }
.login-header h2 { .login-header h2 {
font-size: 1.75rem; font-size: 2.33rem;
font-weight: 800; font-weight: 900;
color: var(--text-main); color: var(--text-main);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.login-header p { .login-header p {
font-size: 0.9375rem; font-size: 1.25rem;
color: var(--text-muted); color: var(--text-muted);
} }
@@ -94,14 +94,14 @@
} }
.role-card h3 { .role-card h3 {
font-size: 1.125rem; font-size: 1.5rem;
font-weight: 700; font-weight: 800;
color: var(--text-main); color: var(--text-main);
margin-bottom: 0.5rem; margin-bottom: 0.5rem;
} }
.role-card p { .role-card p {
font-size: 0.8125rem; font-size: 1.08rem;
color: var(--text-muted); color: var(--text-muted);
line-height: 1.4; line-height: 1.4;
} }
@@ -109,7 +109,7 @@
.login-footer { .login-footer {
margin-top: 3rem; margin-top: 3rem;
text-align: center; text-align: center;
font-size: 0.75rem; font-size: 1rem;
color: var(--text-muted); color: var(--text-muted);
} }

View File

@@ -41,9 +41,11 @@
} }
.modal-header h2 { .modal-header h2 {
font-size: 1.4rem; font-size: 1.86rem;
font-weight: 600; font-weight: 700;
letter-spacing: -0.02em; letter-spacing: -0.02em;
color: var(--white);
line-height: 1.2;
} }
.modal-header .btn-icon { .modal-header .btn-icon {
@@ -51,6 +53,8 @@
cursor: pointer; cursor: pointer;
background: none !important; background: none !important;
border: none !important; border: none !important;
font-size: 2.33rem;
line-height: 1;
} }
.btn-icon { .btn-icon {
@@ -94,100 +98,44 @@
/* Section Title for Grouping */ /* Section Title for Grouping */
.form-section-title { .form-section-title {
grid-column: span 2; grid-column: span 2;
font-size: 1.15rem; font-size: 1.53rem;
font-weight: 700; font-weight: 800;
color: var(--primary-color); color: var(--primary-color);
padding: 1.5rem 0 0.5rem 0; /* 패딩 조정 */ padding: 1.5rem 0 0.5rem 0;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
margin-bottom: 0.5rem; margin-bottom: 0.75rem;
display: flex; display: flex;
align-items: center; align-items: center;
} }
/* Modal Readonly/Edit Mode Interaction */
.grid-form.is-view-mode input,
.grid-form.is-view-mode select,
.grid-form.is-view-mode textarea {
border: none !important;
background-color: transparent !important;
padding-left: 0 !important;
padding-right: 0 !important;
pointer-events: none !important;
color: var(--text-main) !important;
font-weight: 500 !important;
appearance: none !important;
-webkit-appearance: none !important;
-moz-appearance: none !important;
box-shadow: none !important;
}
.grid-form.is-view-mode input[type="file"] {
display: none !important;
}
.grid-form.is-view-mode .btn-helper {
display: none !important;
}
.grid-form.is-view-mode button:not(.btn-loc-action) {
pointer-events: none !important;
background: none !important;
border: none !important;
opacity: 0.8;
}
.grid-form.is-view-mode select::-ms-expand {
display: none !important;
}
.grid-form.is-edit-mode input,
.grid-form.is-edit-mode select,
.grid-form.is-edit-mode textarea {
color: var(--edit-mode-color); /* 수정 시 글자색 변경 */
border: 1px solid var(--border-color);
}
/* 수동 수정 불가 필드 (자산번호 등) 전용 스타일 */
.grid-form input[readonly] {
border-color: transparent !important;
background-color: transparent !important;
pointer-events: none !important;
color: var(--text-main) !important;
font-weight: 500 !important;
cursor: default;
}
.grid-form.is-edit-mode input:focus,
.grid-form.is-edit-mode select:focus,
.grid-form.is-edit-mode textarea:focus {
border-color: var(--edit-mode-color);
box-shadow: 0 0 0 2px var(--edit-mode-focus);
}
.form-section-title:first-child { .form-section-title:first-child {
padding-top: 0.5rem; padding-top: 0;
} }
.form-group label { .form-group label {
font-size: 1.1rem; font-size: 1.47rem;
font-weight: 600; font-weight: 700;
color: var(--text-muted); color: var(--text-muted);
} }
.form-group input, .form-group input,
.form-group select, .form-group select,
.form-group textarea { .form-group textarea {
padding: 0.625rem; height: 38px;
padding: 0 0.625rem;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 4px; border-radius: 4px;
font-family: inherit; font-family: inherit;
font-size: 1.15rem; font-size: 1.53rem;
outline: none; outline: none;
transition: all 0.2s; transition: all 0.2s;
background-color: var(--white); background-color: var(--white);
box-sizing: border-box;
} }
.form-group textarea { .form-group textarea {
height: auto;
padding: 0.625rem;
resize: none; resize: none;
} }
@@ -208,6 +156,29 @@
flex-shrink: 0; flex-shrink: 0;
} }
.modal-footer .btn {
height: 42px;
padding: 0 1.25rem;
font-size: 1.17rem;
}
.edit-only-btn {
/* Dynamic visibility handled in JS, but base style here */
}
/* File Upload Display */
.file-upload-wrapper {
display: flex;
flex-direction: column;
gap: 4px;
}
#hw-file-name-display {
font-size: 1.08rem;
color: var(--text-muted);
}
.footer-actions { .footer-actions {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
@@ -238,8 +209,8 @@
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-radius: 8px; border-radius: 8px;
cursor: pointer; cursor: pointer;
font-size: 18px; font-size: 24px;
font-weight: 500; font-weight: 600;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
@@ -258,7 +229,7 @@
color: var(--primary-color); color: var(--primary-color);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
border-color: var(--border-color); border-color: var(--border-color);
font-weight: 700; font-weight: 800;
} }
.preview-table-container { .preview-table-container {
@@ -295,15 +266,15 @@
.preview-table th { .preview-table th {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
text-align: left; text-align: left;
font-size: 17px; font-size: 23px;
font-weight: 600; font-weight: 700;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
color: var(--text-muted); color: var(--text-muted);
} }
.preview-table td { .preview-table td {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
font-size: 18px; font-size: 24px;
border-bottom: 1px solid #f1f5f9; border-bottom: 1px solid #f1f5f9;
color: var(--text-main); color: var(--text-main);
} }
@@ -338,8 +309,8 @@
} }
.history-header h3 { .history-header h3 {
font-size: 1.25rem; font-size: 1.67rem;
font-weight: 600; font-weight: 700;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 0.5rem; gap: 0.5rem;
@@ -352,7 +323,7 @@
background-color: transparent !important; background-color: transparent !important;
pointer-events: none !important; pointer-events: none !important;
color: var(--text-main) !important; color: var(--text-main) !important;
font-weight: 600 !important; font-weight: 700 !important;
cursor: default; cursor: default;
padding-left: 0 !important; padding-left: 0 !important;
} }
@@ -421,14 +392,14 @@
} }
.history-date { .history-date {
font-size: 11px; font-size: 15px;
color: var(--text-muted); color: var(--text-muted);
font-weight: 500; font-weight: 600;
} }
.history-tag { .history-tag {
font-size: 10px; font-size: 14px;
font-weight: 700; font-weight: 800;
padding: 2px 6px; padding: 2px 6px;
border-radius: 10px; border-radius: 10px;
text-transform: uppercase; text-transform: uppercase;
@@ -441,15 +412,15 @@
.tag-default { background: #f3f4f6; color: #6b7280; } .tag-default { background: #f3f4f6; color: #6b7280; }
.history-user { .history-user {
font-size: 11px; font-size: 15px;
font-weight: 600; font-weight: 700;
color: var(--text-main); color: var(--text-main);
margin-bottom: 6px; margin-bottom: 6px;
display: block; display: block;
} }
.history-details { .history-details {
font-size: 12.5px; font-size: 17px;
color: var(--text-main); color: var(--text-main);
line-height: 1.5; line-height: 1.5;
background: #f8fafc; background: #f8fafc;
@@ -463,14 +434,14 @@
display: inline-block; display: inline-block;
margin: 0 4px; margin: 0 4px;
color: var(--text-muted); color: var(--text-muted);
font-weight: 400; font-weight: 500;
} }
.empty-history { .empty-history {
padding: 2rem 0; padding: 2rem 0;
text-align: center; text-align: center;
color: var(--text-muted); color: var(--text-muted);
font-size: 1.1rem; font-size: 1.47rem;
} }
/* Dashboard Detail Modal Table Fixed Header */ /* Dashboard Detail Modal Table Fixed Header */
@@ -503,8 +474,8 @@
border-bottom: 2px solid var(--border-color); border-bottom: 2px solid var(--border-color);
box-shadow: none; box-shadow: none;
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
font-size: 1.1rem; font-size: 1.47rem;
font-weight: 600; font-weight: 700;
color: var(--text-main); color: var(--text-main);
text-align: left; text-align: left;
white-space: nowrap; white-space: nowrap;
@@ -513,7 +484,7 @@
#dashboard-detail-modal tbody td { #dashboard-detail-modal tbody td {
padding: 0.75rem 1rem; padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border-color); border-bottom: 1px solid var(--border-color);
font-size: 1.1rem; font-size: 1.47rem;
color: var(--text-main); color: var(--text-main);
white-space: nowrap; white-space: nowrap;
} }
@@ -531,8 +502,8 @@
display: inline-block; display: inline-block;
padding: 0.125rem 0.5rem; padding: 0.125rem 0.5rem;
border-radius: 4px; border-radius: 4px;
font-size: 1.05rem; font-size: 1.4rem;
font-weight: 500; font-weight: 600;
line-height: 1.5; line-height: 1.5;
} }
@@ -649,8 +620,8 @@
.image-picker-header h3 { .image-picker-header h3 {
color: white; color: white;
margin: 0; margin: 0;
font-size: 1.25rem; font-size: 1.67rem;
font-weight: 600; font-weight: 700;
} }
.image-picker-content { .image-picker-content {
@@ -702,8 +673,8 @@
justify-content: center; justify-content: center;
gap: 4px; gap: 4px;
padding: 0 6px; padding: 0 6px;
font-size: 10px !important; font-size: 14px !important;
font-weight: 600; font-weight: 700;
border-radius: 4px; border-radius: 4px;
height: 24px; height: 24px;
min-width: 52px; min-width: 52px;
@@ -754,7 +725,7 @@
.ri-line input { .ri-line input {
height: 38px; height: 38px;
box-sizing: border-box; box-sizing: border-box;
font-size: 13px; font-size: 17px;
padding: 0 10px; padding: 0 10px;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 4px; border-radius: 4px;

View File

@@ -10,8 +10,8 @@
} }
.page-title { .page-title {
font-size: 16px; font-size: 21px;
font-weight: 700; font-weight: 800;
color: var(--primary-color); color: var(--primary-color);
display: flex; display: flex;
align-items: center; align-items: center;
@@ -22,7 +22,7 @@
} }
.page-description { .page-description {
font-size: 12px; font-size: 16px;
color: var(--text-muted); color: var(--text-muted);
margin: 0; margin: 0;
line-height: 1.4; line-height: 1.4;
@@ -64,8 +64,8 @@
} }
.search-item label { .search-item label {
font-size: 11px; font-size: 15px;
font-weight: 700; font-weight: 800;
color: var(--text-muted); color: var(--text-muted);
} }
@@ -75,7 +75,7 @@
padding: 0 1rem; padding: 0 1rem;
border: 1px solid var(--border-color); border: 1px solid var(--border-color);
border-radius: 4px; border-radius: 4px;
font-size: 14px; font-size: 19px;
outline: none; outline: none;
background-color: var(--white); background-color: var(--white);
} }
@@ -133,8 +133,8 @@ thead {
th { th {
background-color: var(--bg-light) !important; background-color: var(--bg-light) !important;
font-size: 13px; font-size: 17px;
font-weight: 600; font-weight: 700;
color: var(--text-muted); color: var(--text-muted);
position: sticky; position: sticky;
top: 0; top: 0;
@@ -144,9 +144,9 @@ th {
} }
td { td {
font-size: 13px; font-size: 17px;
color: var(--text-main); color: var(--text-main);
font-weight: 400; font-weight: 500;
} }
tbody tr:hover { tbody tr:hover {
@@ -206,7 +206,7 @@ th.sortable::after {
right: 0.6rem; right: 0.6rem;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
font-size: 11px; font-size: 15px;
opacity: 0.3; opacity: 0.3;
transition: all 0.2s; transition: all 0.2s;
} }
@@ -222,3 +222,91 @@ th.sortable.desc::after {
opacity: 1; opacity: 1;
color: var(--primary-color); color: var(--primary-color);
} }
/* --- Mini Table for System Status --- */
.mini-table {
width: 100%;
border-collapse: collapse;
table-layout: fixed;
}
.mini-table thead {
position: sticky;
top: 0;
background: var(--white);
z-index: 10;
}
.mini-table th {
padding: 10px 0;
font-size: 15px;
font-weight: 800;
color: var(--text-muted);
border-bottom: 2px solid var(--border-color);
background: var(--white);
text-align: center;
}
.mini-table th:nth-child(2) {
text-align: left;
}
.mini-row {
border-bottom: 1px solid var(--border-color);
cursor: pointer;
font-size: 16px;
transition: background-color 0.2s;
}
.mini-row:hover {
background-color: #F8FAFA;
}
.mini-row.active {
background-color: #EBF2F1 !important;
}
.mini-row td {
padding: 10px 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center;
}
.mini-row td:nth-child(2) {
text-align: left;
font-weight: 700;
}
.mini-row.warning {
background-color: #FFF1F2 !important;
border-left: 3px solid #E11D48;
}
.mini-row.warning td {
color: #991B1B !important;
}
.warning-badge {
background: #FFF1F2;
color: #E11D48;
font-size: 14px;
font-weight: 900;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid #FDA4AF;
white-space: nowrap;
}
.warning-badge-orange {
background: #FFF7ED;
color: #C2410C;
font-size: 14px;
font-weight: 900;
padding: 2px 6px;
border-radius: 4px;
border: 1px solid #FFEDD5;
white-space: nowrap;
}

View File

@@ -18,10 +18,10 @@ export function renderHwDashboard(container: HTMLElement) {
// 2. 1페이지 매거진 리포트(제목바 제거, '| 제목' 미니멀리즘 스타일) HTML 빌드 // 2. 1페이지 매거진 리포트(제목바 제거, '| 제목' 미니멀리즘 스타일) HTML 빌드
container.innerHTML = ` container.innerHTML = `
<div class="view-container" style="overflow: hidden; padding: 0.4rem 1.2rem; background-color: #F8FAFC; height: calc(100vh - var(--header-height) - 48px); box-sizing: border-box; display: flex; flex-direction: column; gap: 0.5rem; font-family: 'Pretendard', sans-serif; color: #1E293B;"> <div class="view-container" style="overflow: hidden; padding: 1.5rem 2rem; background-color: #F8FAFC; height: calc(100vh - var(--header-height) - 28px); box-sizing: border-box; display: flex; flex-direction: column; gap: 1.25rem; font-family: 'Pretendard', sans-serif; color: #1E293B;">
<!-- 대시보드 타이틀 및 사용조직 필터 --> <!-- 대시보드 타이틀 및 사용조직 필터 -->
<div style="display: flex; justify-content: space-between; align-items: flex-end; flex-shrink: 0; padding-bottom: 0.4rem;"> <div style="display: flex; justify-content: space-between; align-items: flex-end; flex-shrink: 0; padding-bottom: 0.75rem;">
<div style="border-left: 4px solid #1E5149; padding-left: 8px;"> <div style="border-left: 4px solid #1E5149; padding-left: 8px;">
<h2 style="font-size: 1.65rem; font-weight: 850; color: #1E5149; margin: 0; letter-spacing: -0.5px; display: flex; align-items: center; gap: 0.6rem;"> <h2 style="font-size: 1.65rem; font-weight: 850; color: #1E5149; margin: 0; letter-spacing: -0.5px; display: flex; align-items: center; gap: 0.6rem;">
개인 PC 자산 대시보드 개인 PC 자산 대시보드
@@ -44,62 +44,62 @@ export function renderHwDashboard(container: HTMLElement) {
</div> </div>
<!-- 메인 2단 컬럼 레이아웃 (5:5 비율) --> <!-- 메인 2단 컬럼 레이아웃 (5:5 비율) -->
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; flex: 1; min-height: 0; margin-bottom: 0.1rem;"> <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; flex: 1; min-height: 0; margin-bottom: 0.25rem;">
<!-- 좌측 컬럼 (Left Column) --> <!-- 좌측 컬럼 (Left Column) -->
<div style="display: flex; flex-direction: column; gap: 0.5rem; min-height: 0;"> <div style="display: flex; flex-direction: column; gap: 1.25rem; min-height: 0;">
<!-- 핵심 지표 카드 --> <!-- 상단 핵심 지표 그룹 카드 (1개 카드로 통합, 4개 지표 가로 배치) -->
<div class="stat-card" style="background: transparent; border-radius: 0; padding: 0.75rem 0.25rem; border: none; border-bottom: 1px solid #E2E8F0; display: grid !important; grid-template-columns: 1fr 1fr; gap: 0.6rem 0.9rem; flex-shrink: 0;"> <div class="stat-card" style="background: transparent; border-radius: 0; padding: 0.85rem 0.25rem; border: none; border-bottom: 1px solid #E2E8F0; display: flex !important; flex-direction: row !important; align-items: center; justify-content: space-between; flex-shrink: 0; gap: 0;">
<!-- 1. 보유 자산 수량 --> <!-- 1. 보유 자산 수량 -->
<div style="border-right: 1px solid #EEF2F6; border-bottom: 1px solid #EEF2F6; padding-bottom: 0.65rem; padding-right: 1.0rem;"> <div style="flex: 1; border-right: 1px solid #EEF2F6; padding-right: 0.85rem;">
<div style="border-left: 4px solid #1E5149; padding-left: 8px; margin-bottom: 0.5rem; display: flex; align-items: center; line-height: 1;"> <div style="border-left: 4px solid #1E5149; padding-left: 8px; margin-bottom: 0.5rem; display: flex; align-items: center; line-height: 1;">
<span style="font-size: 1.15rem; font-weight: 800; color: #1E293B; white-space: nowrap;">보유 자산 수량</span> <span style="font-size: 1.03rem; font-weight: 800; color: #1E293B; white-space: nowrap;">보유 자산 수량</span>
</div> </div>
<div style="display: flex; align-items: flex-end; justify-content: space-between;"> <div style="display: flex; align-items: flex-end; justify-content: space-between;">
<div> <div>
<div id="metric-total-pcs" style="font-size: 2.3rem; font-weight: 900; color: #1E5149; line-height: 1; margin-bottom: 0.35rem;">0대</div> <div id="metric-total-pcs" style="font-size: 1.96rem; font-weight: 900; color: #1E5149; line-height: 1; margin-bottom: 0.2rem;">0대</div>
<span style="font-size: 1.05rem; color: #64748B; font-weight: 700; white-space: nowrap;">전사 보유 개인용 PC</span> <span style="font-size: 0.93rem; color: #64748B; font-weight: 700; white-space: nowrap;">전사 보유 개인용 PC</span>
</div> </div>
</div> </div>
</div> </div>
<!-- 2. 사양 부족 --> <!-- 2. 사양 부족 검토 -->
<div id="card-under-spec" style="border-bottom: 1px solid #EEF2F6; padding-bottom: 0.65rem; padding-left: 1.0rem; cursor: pointer; transition: opacity 0.2s;" onmouseover="this.style.opacity='0.7'" onmouseout="this.style.opacity='1'"> <div id="card-under-spec" style="flex: 1; border-right: 1px solid #EEF2F6; padding-left: 0.85rem; padding-right: 0.85rem; cursor: pointer; transition: opacity 0.2s;" onmouseover="this.style.opacity='0.7'" onmouseout="this.style.opacity='1'">
<div style="border-left: 4px solid #EF4444; padding-left: 8px; margin-bottom: 0.5rem; display: flex; align-items: center; line-height: 1;"> <div style="border-left: 4px solid #EF4444; padding-left: 8px; margin-bottom: 0.5rem; display: flex; align-items: center; line-height: 1;">
<span style="font-size: 1.15rem; font-weight: 800; color: #1E293B; white-space: nowrap;">사양 부족</span> <span style="font-size: 1.03rem; font-weight: 800; color: #1E293B; white-space: nowrap;">사양 부족 검토</span>
</div> </div>
<div style="display: flex; align-items: flex-end; justify-content: space-between;"> <div style="display: flex; align-items: flex-end; justify-content: space-between;">
<div> <div>
<div id="metric-under-spec" style="font-size: 2.3rem; font-weight: 900; color: #EF4444; line-height: 1; margin-bottom: 0.35rem;">0</div> <div id="metric-under-spec" style="font-size: 1.96rem; font-weight: 900; color: #EF4444; line-height: 1; margin-bottom: 0.2rem;">0</div>
<span style="font-size: 1.05rem; color: #64748B; font-weight: 700; white-space: nowrap;">사양 교체 권고 자산</span> <span style="font-size: 0.93rem; color: #64748B; font-weight: 700; white-space: nowrap;">사양 교체 권고 자산</span>
</div> </div>
</div> </div>
</div> </div>
<!-- 3. 오버 스펙 --> <!-- 3. 오버스펙 검토 -->
<div id="card-over-spec" style="border-right: 1px solid #EEF2F6; padding-top: 0.65rem; padding-right: 1.0rem; cursor: pointer; transition: opacity 0.2s;" onmouseover="this.style.opacity='0.7'" onmouseout="this.style.opacity='1'"> <div id="card-over-spec" style="flex: 1; border-right: 1px solid #EEF2F6; padding-left: 0.85rem; padding-right: 0.85rem; cursor: pointer; transition: opacity 0.2s;" onmouseover="this.style.opacity='0.7'" onmouseout="this.style.opacity='1'">
<div style="border-left: 4px solid #F59E0B; padding-left: 8px; margin-bottom: 0.5rem; display: flex; align-items: center; line-height: 1;"> <div style="border-left: 4px solid #F59E0B; padding-left: 8px; margin-bottom: 0.5rem; display: flex; align-items: center; line-height: 1;">
<span style="font-size: 1.15rem; font-weight: 800; color: #1E293B; white-space: nowrap;">오버 스펙</span> <span style="font-size: 1.03rem; font-weight: 800; color: #1E293B; white-space: nowrap;">오버스펙 검토</span>
</div> </div>
<div style="display: flex; align-items: flex-end; justify-content: space-between;"> <div style="display: flex; align-items: flex-end; justify-content: space-between;">
<div> <div>
<div id="metric-over-spec" style="font-size: 2.3rem; font-weight: 900; color: #F59E0B; line-height: 1; margin-bottom: 0.35rem;">0</div> <div id="metric-over-spec" style="font-size: 1.96rem; font-weight: 900; color: #F59E0B; line-height: 1; margin-bottom: 0.2rem;">0</div>
<span style="font-size: 1.05rem; color: #64748B; font-weight: 700; white-space: nowrap;">사양 회수 권고 자산</span> <span style="font-size: 0.93rem; color: #64748B; font-weight: 700; white-space: nowrap;">사양 회수 권고 자산</span>
</div> </div>
</div> </div>
</div> </div>
<!-- 4. 윈도우 11 불가 PC --> <!-- 4. 윈도우 11 불가 PC -->
<div id="card-win11-incompatible" style="padding-top: 0.65rem; padding-left: 1.0rem; cursor: pointer; transition: opacity 0.2s;" onmouseover="this.style.opacity='0.7'" onmouseout="this.style.opacity='1'"> <div id="card-win11-incompatible" style="flex: 1; padding-left: 0.85rem; padding-right: 0.25rem; cursor: pointer; transition: opacity 0.2s;" onmouseover="this.style.opacity='0.7'" onmouseout="this.style.opacity='1'">
<div style="border-left: 4px solid #3B82F6; padding-left: 8px; margin-bottom: 0.5rem; display: flex; align-items: center; line-height: 1;"> <div style="border-left: 4px solid #3B82F6; padding-left: 8px; margin-bottom: 0.5rem; display: flex; align-items: center; line-height: 1;">
<span style="font-size: 1.15rem; font-weight: 800; color: #1E293B; white-space: nowrap;">윈도우 11 불가 PC</span> <span style="font-size: 1.03rem; font-weight: 800; color: #1E293B; white-space: nowrap;">윈도우 11 불가 PC</span>
</div> </div>
<div style="display: flex; align-items: flex-end; justify-content: space-between;"> <div style="display: flex; align-items: flex-end; justify-content: space-between;">
<div> <div>
<div id="metric-win11-incompatible" style="font-size: 2.3rem; font-weight: 900; color: #3B82F6; line-height: 1; margin-bottom: 0.35rem;">0대</div> <div id="metric-win11-incompatible" style="font-size: 1.96rem; font-weight: 900; color: #3B82F6; line-height: 1; margin-bottom: 0.2rem;">0대</div>
<span style="font-size: 1.05rem; color: #64748B; font-weight: 700; white-space: nowrap;">업데이트 미지원 하드웨어</span> <span style="font-size: 0.93rem; color: #64748B; font-weight: 700; white-space: nowrap;">업데이트 미지원 하드웨어</span>
</div> </div>
</div> </div>
</div> </div>
@@ -107,68 +107,63 @@ export function renderHwDashboard(container: HTMLElement) {
</div> </div>
<!-- 등급별 자산 종합 현황 (좌측 하단 단독 배치 및 크기 확대) --> <!-- PC 등급별 보유 현황 (등급별 게이지 + 우측 사양 적정성 도넛차트) -->
<div style="background: transparent; border-radius: 0; padding: 0.75rem 0.25rem; border: none; border-bottom: 1px solid #E2E8F0; display: flex; flex-direction: column; flex: 1.0; min-height: 0;"> <div style="background: transparent; border-radius: 0; padding: 1.25rem 0.25rem; border: none; border-bottom: 1px solid #E2E8F0; display: grid; grid-template-columns: 1.3fr 1fr; gap: 1.75rem; flex: 1.1; min-height: 0;">
<div style="display: flex; flex-direction: column; gap: 0.9rem; justify-content: flex-start; padding-left: 0.5rem; height: 100%;"> <!-- 1열: 등급별 보유 현황 리스트 영역 -->
<div style="display: flex; flex-direction: column; gap: 0.6rem; justify-content: center; padding-left: 0.5rem;">
<!-- 메인 제목 --> <!-- 메인 제목 -->
<div style="border-left: 4px solid #1E5149; padding-left: 8px; margin-bottom: 0.4rem; display: flex; align-items: center; line-height: 1; height: 1.7rem; flex-shrink: 0;"> <div style="border-left: 4px solid #1E5149; padding-left: 8px; margin-bottom: 0.35rem; display: flex; align-items: center; line-height: 1; height: 1.5rem;">
<span style="font-size: 1.25rem; font-weight: 850; color: #1E293B;">등급별 자산 종합 현황</span> <span style="font-size: 1.11rem; font-weight: 800; color: #1E293B;">등급별 보유 현황</span>
</div> </div>
<!-- 종합 매트릭스 테이블 (폰트 크기 1.25rem 으로 확대 및 꽉 채우기) --> <!-- 등급 리스트 (바 그래프 제거 및 폰트 확대, 간격 조정) -->
<div style="width: 100%; overflow-x: auto; flex: 1; display: flex; align-items: stretch;"> <div style="display: flex; flex-direction: column; gap: 0.65rem; padding: 4px 0;">
<table style="width: 100%; border-collapse: collapse; text-align: left; font-size: 1.25rem; height: 100%;"> <!-- 최상급 -->
<thead> <div id="grade-premium" style="cursor: pointer; display: flex; flex-direction: column; padding: 2px 0;">
<tr style="border-bottom: 2px solid #1E5149; color: #475569; font-weight: 850;"> <div style="display: flex; align-items: center; gap: 0.75rem; font-size: 1.36rem; font-weight: 800;">
<th style="padding: 14px 10px; width: 32%; font-size: 1.25rem;">구분 (등급)</th> <span style="color: #11302B; white-space: nowrap; width: 260px; display: inline-block;">최상급 PC (85점 이상)</span>
<th style="padding: 14px 10px; text-align: center; width: 17%; font-size: 1.25rem;">보유량</th> <span class="grade-info" style="color: #334155; display: flex; align-items: center; gap: 4px; white-space: nowrap;"><span class="grade-count">0대</span><span class="grade-rate" style="color:#64748B; font-size:1.21rem;">(0%)</span></span>
<th style="padding: 14px 10px; text-align: center; width: 17%; font-size: 1.25rem;">운영중</th> </div>
<th style="padding: 14px 10px; text-align: center; width: 17%; font-size: 1.25rem;">재고</th> </div>
<th style="padding: 14px 10px; text-align: center; width: 17%; color: #EF4444; font-size: 1.25rem;">구매 필요</th> <!-- 상급 -->
</tr> <div id="grade-high" style="cursor: pointer; display: flex; flex-direction: column; padding: 2px 0;">
</thead> <div style="display: flex; align-items: center; gap: 0.75rem; font-size: 1.36rem; font-weight: 800;">
<tbody id="pc-grade-matrix-tbody"> <span style="color: #1E8E7C; white-space: nowrap; width: 260px; display: inline-block;">상급 PC (70점 ~ 85점)</span>
<!-- Dynamic Matrix Contents --> <span class="grade-info" style="color: #334155; display: flex; align-items: center; gap: 4px; white-space: nowrap;"><span class="grade-count">0대</span><span class="grade-rate" style="color:#64748B; font-size:1.21rem;">(0%)</span></span>
</tbody> </div>
</table> </div>
<!-- 중급 -->
<div id="grade-normal" style="cursor: pointer; display: flex; flex-direction: column; padding: 2px 0;">
<div style="display: flex; align-items: center; gap: 0.75rem; font-size: 1.36rem; font-weight: 800;">
<span style="color: #10B981; white-space: nowrap; width: 260px; display: inline-block;">중급 PC (40점 ~ 70점)</span>
<span class="grade-info" style="color: #334155; display: flex; align-items: center; gap: 4px; white-space: nowrap;"><span class="grade-count">0대</span><span class="grade-rate" style="color:#64748B; font-size:1.21rem;">(0%)</span></span>
</div>
</div>
<!-- 보급 -->
<div id="grade-entry" style="cursor: pointer; display: flex; flex-direction: column; padding: 2px 0;">
<div style="display: flex; align-items: center; gap: 0.75rem; font-size: 1.36rem; font-weight: 800;">
<span style="color: #64748B; white-space: nowrap; width: 260px; display: inline-block;">보급 PC (40점 미만)</span>
<span class="grade-info" style="color: #334155; display: flex; align-items: center; gap: 4px; white-space: nowrap;"><span class="grade-count">0대</span><span class="grade-rate" style="color:#64748B; font-size:1.21rem;">(0%)</span></span>
</div>
</div>
</div> </div>
</div> </div>
</div> <!-- 2열: 등급별 보유 비율 도넛 영역 -->
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 0.8rem;">
</div> <!-- 서브 제목 (메인 제목과 수평 정렬을 맞추고, 중간정렬을 위해 text-align: center) -->
<div style="margin-bottom: 0.35rem; display: flex; align-items: center; justify-content: center; line-height: 1; height: 1.5rem;">
<!-- 우측 컬럼 (Right Column) --> <span style="font-size: 1.06rem; font-weight: 800; color: #475569; text-transform: uppercase;">등급별 보유 비율</span>
<div style="display: flex; flex-direction: column; gap: 0.5rem; min-height: 0;">
<!-- 직무별 사양 적정성 분석 차트 카드 -->
<div style="background: transparent; border-radius: 0; padding: 0.7rem 0.25rem; border: none; border-bottom: 1px solid #E2E8F0; display: flex; flex-direction: column; flex: 1.0; min-height: 0;">
<div style="border-left: 4px solid #1E5149; padding-left: 8px; margin-bottom: 0.5rem; display: flex; align-items: center; line-height: 1; flex-shrink: 0;">
<span style="font-size: 1.25rem; font-weight: 850; color: #1E293B;">직무별 사양 적정성 분석</span>
</div>
<div style="flex: 1; min-height: 0; width: 100%; position: relative;">
<canvas id="chart-job-scores" style="width: 100%; height: 100%;"></canvas>
</div>
</div>
<!-- 우측 하단: 등급별 보유 비율 도넛 & 연도별 PC 노후도 통합 배치 (너비 축소) -->
<div style="background: transparent; border-radius: 0; padding: 0.7rem 0.25rem; border: none; border-bottom: 1px solid #E2E8F0; display: grid; grid-template-columns: 1.15fr 1.25fr; gap: 0.8rem; flex: 1.0; min-height: 0;">
<!-- 1열: 등급별 보유 비율 도넛 영역 -->
<div style="display: flex; flex-direction: column; align-items: center; justify-content: flex-start; gap: 0.7rem; padding-top: 0.1rem; min-height: 0; height: 100%;">
<!-- 서브 제목 -->
<div style="width: 100%; border-left: 4px solid #1E5149; padding-left: 8px; margin-bottom: 0.5rem; display: flex; align-items: center; line-height: 1; flex-shrink: 0; height: 1.5rem;">
<span style="font-size: 1.25rem; font-weight: 850; color: #1E293B;">등급별 보유 비율</span>
</div> </div>
<!-- 도넛 그래프 (크기 조절수직 가운데 정렬) --> <!-- 도넛 그래프 (크기 확대범례 추가, 제목 상단 이관) -->
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; flex: 1; width: 100%; min-height: 0;"> <div style="display: flex; flex-direction: column; align-items: center; justify-content: center; flex-shrink: 0; padding-right: 0.25rem; width: 100%;">
<div style="width: 180px; height: 180px; position: relative;"> <div style="width: 170px; height: 170px; position: relative;">
<canvas id="chart-overall-donut"></canvas> <canvas id="chart-overall-donut"></canvas>
</div> </div>
<!-- 커스텀 범례 (폰트 최적화) --> <!-- 커스텀 범례 -->
<div style="display: flex; flex-wrap: wrap; gap: 0.4rem 0.6rem; justify-content: center; align-items: center; margin-top: 10px; font-size: 1.05rem; font-weight: 700; color: #475569; width: 100%;"> <div style="display: flex; gap: 0.65rem; justify-content: center; align-items: center; margin-top: 4px; font-size: 1.01rem; font-weight: 700; color: #475569;">
<div style="display: flex; align-items: center; gap: 4px;"> <div style="display: flex; align-items: center; gap: 4px;">
<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #11302B;"></span> <span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #11302B;"></span>
<span>최상급</span> <span>최상급</span>
@@ -182,41 +177,87 @@ export function renderHwDashboard(container: HTMLElement) {
<span>중급</span> <span>중급</span>
</div> </div>
<div style="display: flex; align-items: center; gap: 4px;"> <div style="display: flex; align-items: center; gap: 4px;">
<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #F59E0B;"></span> <span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #94A3B8;"></span>
<span>보급</span> <span>보급</span>
</div> </div>
<div style="display: flex; align-items: center; gap: 4px;">
<span style="display: inline-block; width: 8px; height: 8px; border-radius: 50%; background: #EF4444;"></span>
<span>교체 대상</span>
</div>
</div> </div>
</div> </div>
</div> </div>
<!-- 2열: 연도별 PC 노후도 및 교체 주기 예측 카드 (너비 줄임) --> </div>
<div style="display: flex; flex-direction: column; min-height: 0;">
<div style="border-left: 4px solid #1E5149; padding-left: 8px; margin-bottom: 0.5rem; display: flex; align-items: center; line-height: 1; flex-shrink: 0; height: 1.5rem;"> <!-- 유효 재고 현황 -->
<span style="font-size: 1.25rem; font-weight: 850; color: #1E293B; white-space: nowrap;">연도별 PC 노후도 및 예측</span> <div style="background: transparent; border-radius: 0; padding: 1.25rem 0.25rem; border: none; border-bottom: 1px solid #E2E8F0; display: flex; flex-direction: column; gap: 0.8rem; flex: 0.9; min-height: 0;">
<div style="border-left: 4px solid #1E5149; padding-left: 8px; margin-bottom: 0.35rem; display: flex; align-items: center; line-height: 1;">
<span style="font-size: 1.11rem; font-weight: 800; color: #1E293B;">유효 재고 현황</span>
</div>
<div style="display: grid; grid-template-columns: 1fr 1px 1fr; gap: 0.5rem; flex: 1; align-items: center;">
<!-- 좌측 열 (최상급 재고, 중급 재고) -->
<div style="display: flex; flex-direction: column; gap: 1rem; width: 100%;">
<div id="stock-premium-card" style="text-align: center; width: 100%; cursor: pointer; transition: opacity 0.2s;" onmouseover="this.style.opacity='0.7'" onmouseout="this.style.opacity='1'">
<div class="summary-grade-stock-premium" style="font-size: 2.01rem; font-weight: 900; color: #11302B; line-height: 1; margin-bottom: 0.25rem;">0대</div>
<span style="font-size: 1.03rem; color: #64748B; font-weight: 700;">최상급 재고</span>
</div>
<div id="stock-normal-card" style="text-align: center; width: 100%; cursor: pointer; transition: opacity 0.2s;" onmouseover="this.style.opacity='0.7'" onmouseout="this.style.opacity='1'">
<div class="summary-grade-stock-normal" style="font-size: 2.01rem; font-weight: 900; color: #10B981; line-height: 1; margin-bottom: 0.25rem;">0대</div>
<span style="font-size: 1.03rem; color: #64748B; font-weight: 700;">중급 재고</span>
</div>
</div> </div>
<div style="flex: 1; overflow: hidden; min-height: 0; padding-right: 0.2rem;"> <!-- 중앙 세로선 -->
<table style="width: 100%; border-collapse: collapse; text-align: left; font-size: 1.15rem;"> <div style="width: 1px; height: 80%; background-color: #EEF2F6; align-self: center;"></div>
<thead style="position: sticky; top: 0; background: white; z-index: 5;"> <!-- 우측 열 (상급 재고, 보급 재고) -->
<tr style="border-bottom: 2px solid #1E5149; color: #475569; font-weight: 850;"> <div style="display: flex; flex-direction: column; gap: 1rem; width: 100%;">
<th style="padding: 12px 10px; width: 45%; font-size: 1.15rem;">구분 (연한)</th> <div id="stock-high-card" style="text-align: center; width: 100%; cursor: pointer; transition: opacity 0.2s;" onmouseover="this.style.opacity='0.7'" onmouseout="this.style.opacity='1'">
<th style="padding: 12px 10px; text-align: center; width: 25%; font-size: 1.15rem;">보유</th> <div class="summary-grade-stock-high" style="font-size: 2.01rem; font-weight: 900; color: #1E8E7C; line-height: 1; margin-bottom: 0.25rem;">0대</div>
<th style="padding: 12px 10px; text-align: center; width: 30%; font-size: 1.15rem;">권장 조치</th> <span style="font-size: 1.03rem; color: #64748B; font-weight: 700;">상급 재고</span>
</tr> </div>
</thead> <div id="stock-entry-card" style="text-align: center; width: 100%; cursor: pointer; transition: opacity 0.2s;" onmouseover="this.style.opacity='0.7'" onmouseout="this.style.opacity='1'">
<tbody id="pc-aging-tbody"> <div class="summary-grade-stock-entry" style="font-size: 2.01rem; font-weight: 900; color: #94A3B8; line-height: 1; margin-bottom: 0.25rem;">0대</div>
<!-- Dynamic Aging Contents --> <span style="font-size: 1.03rem; color: #64748B; font-weight: 700;">보급 재고</span>
</tbody> </div>
</table>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<!-- 우측 컬럼 (Right Column) -->
<div style="display: flex; flex-direction: column; gap: 1.25rem; min-height: 0;">
<!-- 직무별 사양 적정성 분석 차트 카드 -->
<div style="background: transparent; border-radius: 0; padding: 1.5rem 0.25rem; border: none; border-bottom: 1px solid #E2E8F0; display: flex; flex-direction: column; flex: 1.1; min-height: 0;">
<div style="border-left: 4px solid #1E5149; padding-left: 8px; margin-bottom: 0.9rem; display: flex; align-items: center; line-height: 1; flex-shrink: 0;">
<span style="font-size: 1.11rem; font-weight: 800; color: #1E293B;">직무별 사양 적정성 분석</span>
</div>
<div style="flex: 1; min-height: 0; width: 100%; position: relative;">
<canvas id="chart-job-scores" style="width: 100%; height: 100%;"></canvas>
</div>
</div>
<!-- 연도별 PC 노후도 및 교체 주기 예측 카드 -->
<div style="background: transparent; border-radius: 0; padding: 1.5rem 0.25rem; border: none; border-bottom: 1px solid #E2E8F0; display: flex; flex-direction: column; flex: 0.9; min-height: 0;">
<div style="border-left: 4px solid #1E5149; padding-left: 8px; margin-bottom: 0.9rem; display: flex; align-items: center; line-height: 1; flex-shrink: 0;">
<span style="font-size: 1.11rem; font-weight: 800; color: #1E293B;">연도별 PC 노후도 및 교체 주기 예측</span>
</div>
<div style="flex: 1; overflow: hidden; min-height: 0;">
<table style="width: 100%; border-collapse: collapse; text-align: left; font-size: 1.11rem;">
<thead style="position: sticky; top: 0; background: white; z-index: 5;">
<tr style="border-bottom: 2px solid #1E5149; color: #475569; font-weight: 800;">
<th style="padding: 10px 4px; width: 50%;">구분 (사용 연한)</th>
<th style="padding: 10px 4px; width: 25%; text-align: center;">보유 대수</th>
<th style="padding: 10px 4px; width: 25%; text-align: center;">권장 조치</th>
</tr>
</thead>
<tbody id="pc-aging-tbody">
<!-- Dynamic Aging Contents -->
</tbody>
</table>
</div>
</div>
</div>
</div> </div>
</div> </div>
`; `;
@@ -265,14 +306,7 @@ function updateDashboardData(pcs: any[], selectedDept: string) {
p._pc_score = calculatePcScoreDeductive(p.cpu, p.ram, p.gpu, p.purchase_date); p._pc_score = calculatePcScoreDeductive(p.cpu, p.ram, p.gpu, p.purchase_date);
}); });
// 3. DB 기준 사양 데이터 맵핑 (state.masterData.jobSpecs 이용) // 3. 전사 직무군별 평균 점수 산출
const jobSpecsMap: Record<string, number> = {};
if (state.masterData.jobSpecs) {
state.masterData.jobSpecs.forEach((s: any) => {
jobSpecsMap[s.job_name] = s.min_score;
});
}
const jobScores: Record<string, { totalScore: number; count: number; avg: number }> = {}; const jobScores: Record<string, { totalScore: number; count: number; avg: number }> = {};
pcs.forEach((p: any) => { pcs.forEach((p: any) => {
const score = calculatePcScoreDeductive(p.cpu, p.ram, p.gpu, p.purchase_date); const score = calculatePcScoreDeductive(p.cpu, p.ram, p.gpu, p.purchase_date);
@@ -285,19 +319,18 @@ function updateDashboardData(pcs: any[], selectedDept: string) {
jobScores[job].avg = jobScores[job].count > 0 ? jobScores[job].totalScore / jobScores[job].count : 0; jobScores[job].avg = jobScores[job].count > 0 ? jobScores[job].totalScore / jobScores[job].count : 0;
}); });
// 4. 등급 집계 (보유량 vs 실제 할당량 vs 유효 재고량 vs 사양 부족량) // 4. 등급 집계 (보유량 vs 유효 재고량)
const isStock = (p: any) => { const isStock = (p: any) => {
return p.hw_status === '재고' || return p.hw_status === '재고' ||
p.hw_status === '대기' || p.hw_status === '대기' ||
!(p.user_current || '').trim(); !(p.user_current || '').trim();
}; };
const matrix = { const gradeCounts = {
premium: { total: 0, active: 0, stock: 0, under: 0, pcs: [] as any[], activePcs: [] as any[], stockPcs: [] as any[], underPcs: [] as any[] }, premium: { total: 0, stock: 0 },
high: { total: 0, active: 0, stock: 0, under: 0, pcs: [] as any[], activePcs: [] as any[], stockPcs: [] as any[], underPcs: [] as any[] }, high: { total: 0, stock: 0 },
normal: { total: 0, active: 0, stock: 0, under: 0, pcs: [] as any[], activePcs: [] as any[], stockPcs: [] as any[], underPcs: [] as any[] }, normal: { total: 0, stock: 0 },
entry: { total: 0, active: 0, stock: 0, under: 0, pcs: [] as any[], activePcs: [] as any[], stockPcs: [] as any[], underPcs: [] as any[] }, entry: { total: 0, stock: 0 }
replace: { total: 0, active: 0, stock: 0, under: 0, pcs: [] as any[], activePcs: [] as any[], stockPcs: [] as any[], underPcs: [] as any[] }
}; };
let scoreSum = 0; let scoreSum = 0;
@@ -310,81 +343,36 @@ function updateDashboardData(pcs: any[], selectedDept: string) {
const score = p._pc_score; const score = p._pc_score;
scoreSum += score; scoreSum += score;
const stockYn = isStock(p); const stockYn = isStock(p);
const win11Incompatible = isWindows11Incompatible(p.cpu, p.ram);
// 1. 현재 물리적 자산 등급 판정
let currentGradeKey: keyof typeof matrix;
if (score >= 85) { if (score >= 85) {
currentGradeKey = 'premium'; gradeCounts.premium.total++;
if (stockYn) gradeCounts.premium.stock++;
} else if (score >= 70) { } else if (score >= 70) {
currentGradeKey = 'high'; gradeCounts.high.total++;
if (stockYn) gradeCounts.high.stock++;
} else if (score >= 40) { } else if (score >= 40) {
currentGradeKey = 'normal'; gradeCounts.normal.total++;
} else if (score >= 20 && !win11Incompatible) { if (stockYn) gradeCounts.normal.stock++;
currentGradeKey = 'entry';
} else { } else {
currentGradeKey = 'replace'; gradeCounts.entry.total++;
if (stockYn) gradeCounts.entry.stock++;
} }
const currentTarget = matrix[currentGradeKey]; // 직무 적정성 계산 (재직 중이고 실 사용자 매핑 자산만 검토 대상)
currentTarget.pcs.push(p); const job = p[ASSET_SCHEMA.USER_POSITION.key] || '미분류';
currentTarget.total++; const avg = jobScores[job]?.avg || 0;
if (stockYn) { if (avg > 0 && job !== '재고PC' && !stockYn) {
currentTarget.stock++; if (score < avg * 0.6) {
currentTarget.stockPcs.push(p); p._spec_status = '사양 부족';
} else {
currentTarget.active++;
currentTarget.activePcs.push(p);
// 직무 적정성 계산 (재직 중이고 실 사용자 매핑 자산만 검토 대상)
const job = p[ASSET_SCHEMA.USER_POSITION.key] || '미분류';
const standardScore = jobSpecsMap[job] !== undefined ? jobSpecsMap[job] : (jobScores[job]?.avg || 0);
let isUnder = false;
if (standardScore > 0 && job !== '재고PC') {
if (score < standardScore * 0.6) {
isUnder = true;
p._spec_status = '사양 부족';
} else if (score > standardScore * 1.5 && !win11Incompatible) {
p._spec_status = '오버스펙';
criticalList.push(p);
overSpecCount++;
} else if (win11Incompatible) {
isUnder = true;
p._spec_status = '사양 부족';
} else {
p._spec_status = '적정';
}
} else {
if (win11Incompatible) {
isUnder = true;
p._spec_status = '사양 부족';
} else {
p._spec_status = '적정';
}
}
if (isUnder) {
criticalList.push(p); criticalList.push(p);
underSpecCount++; underSpecCount++;
} else if (score > avg * 1.5) {
// 2. 사양 부족 시 교체받아야 할 직무별 권장 목표 등급 판정 p._spec_status = '오버스펙';
let targetGradeKey: keyof typeof matrix; criticalList.push(p);
if (standardScore >= 85) { overSpecCount++;
targetGradeKey = 'premium'; } else {
} else if (standardScore >= 70) { p._spec_status = '적정';
targetGradeKey = 'high';
} else if (standardScore >= 40) {
targetGradeKey = 'normal';
} else {
targetGradeKey = 'entry'; // 교체 대상은 최소 보급형 사양으로 교체
}
const targetGrade = matrix[targetGradeKey];
targetGrade.under++;
targetGrade.underPcs.push(p);
} }
} }
@@ -394,120 +382,39 @@ function updateDashboardData(pcs: any[], selectedDept: string) {
} }
}); });
// 5. 핵심 텍스트형 요약 지표 갱신 // 5. 핵심 텍스트형 지표 갱신
document.getElementById('metric-total-pcs')!.textContent = `${filtered.length}`; document.getElementById('metric-total-pcs')!.textContent = `${filtered.length}`;
document.getElementById('metric-under-spec')!.textContent = `${underSpecCount}`; document.getElementById('metric-under-spec')!.textContent = `${underSpecCount}`;
document.getElementById('metric-over-spec')!.textContent = `${overSpecCount}`; document.getElementById('metric-over-spec')!.textContent = `${overSpecCount}`;
document.getElementById('metric-win11-incompatible')!.textContent = `${win11IncompatibleCount}`; document.getElementById('metric-win11-incompatible')!.textContent = `${win11IncompatibleCount}`;
// 6. 종합 매트릭스 테이블 렌더링 및 바인딩
const matrixTbody = document.getElementById('pc-grade-matrix-tbody')!;
const renderMatrixRow = (gradeKey: keyof typeof matrix, label: string, color: string, shortage: number) => { // 6. 등급별 리스트 데이터 바 업데이트
const data = matrix[gradeKey]; const total = filtered.length || 1;
const totalRate = filtered.length > 0 ? Math.round((data.total / filtered.length) * 100) : 0;
const cellStyle = `padding: 14px 12px; text-align: center; font-weight: 700; cursor: pointer; transition: background 0.2s; font-size: 1.25rem;`; const updateCard = (id: string, counts: { total: number; stock: number }) => {
const hoverEvents = `onmouseover="this.style.background='#F1F5F9'" onmouseout="this.style.background='none'"`; const card = document.getElementById(id)!;
const rate = Math.round((counts.total / total) * 100);
return ` card.querySelector('.grade-count')!.textContent = `${counts.total}`;
<tr style="border-bottom: 1px solid #F1F5F9;"> card.querySelector('.grade-rate')!.textContent = `(${rate}%)`;
<td style="padding: 14px 12px; font-weight: 800; color: ${color}; font-size: 1.25rem;">${label}</td>
<td class="matrix-cell" data-grade="${gradeKey}" data-type="total" style="${cellStyle}" ${hoverEvents}>${data.total}대 <span style="font-size:1.0rem; color:#64748B; font-weight:500;">(${totalRate}%)</span></td>
<td class="matrix-cell" data-grade="${gradeKey}" data-type="active" style="${cellStyle}" ${hoverEvents}>${data.active}대</td>
<td class="matrix-cell" data-grade="${gradeKey}" data-type="stock" style="${cellStyle}" ${hoverEvents}>${data.stock}대</td>
<td class="matrix-cell" data-grade="${gradeKey}" data-type="under" style="${cellStyle} color: #EF4444;" ${hoverEvents}>${shortage}대</td>
</tr>
`;
}; };
const totalPcs = filtered.length; updateCard('grade-premium', gradeCounts.premium);
const totalActive = matrix.premium.active + matrix.high.active + matrix.normal.active + matrix.entry.active + matrix.replace.active; updateCard('grade-high', gradeCounts.high);
const totalStock = matrix.premium.stock + matrix.high.stock + matrix.normal.stock + matrix.entry.stock + matrix.replace.stock; updateCard('grade-normal', gradeCounts.normal);
updateCard('grade-entry', gradeCounts.entry);
const premiumShortage = Math.max(0, matrix.premium.under - matrix.premium.stock); // 6.2 Inventory Summary 수치 업데이트 (골드/민트 텍스트 영역)
const highShortage = Math.max(0, matrix.high.under - matrix.high.stock); const container = document.getElementById('view-body')?.parentElement || document.body;
const normalShortage = Math.max(0, matrix.normal.under - matrix.normal.stock); const setStockVal = (cls: string, val: number) => {
const el = container.querySelector(`.${cls}`);
// 보급 PC 구매 필요 = 보급 under - 보급 stock if (el) el.textContent = `${val}`;
const entryShortage = Math.max(0, matrix.entry.under - matrix.entry.stock); };
setStockVal('summary-grade-stock-premium', gradeCounts.premium.stock);
// 교체 대상 PC 자체는 새로 구매하는 기종이 아니므로 구매 필요 0대 setStockVal('summary-grade-stock-high', gradeCounts.high.stock);
const replaceShortage = 0; setStockVal('summary-grade-stock-normal', gradeCounts.normal.stock);
setStockVal('summary-grade-stock-entry', gradeCounts.entry.stock);
const totalShortage = premiumShortage + highShortage + normalShortage + entryShortage + replaceShortage;
const cellStyleHeader = `padding: 14px 12px; text-align: center; font-weight: 800; cursor: pointer; transition: background 0.2s; background: #F8FAFC; font-size: 1.25rem;`;
const hoverEventsHeader = `onmouseover="this.style.background='#EEF2F6'" onmouseout="this.style.background='#F8FAFC'"`;
matrixTbody.innerHTML = `
${renderMatrixRow('premium', '최상급 PC (85점 이상)', '#11302B', premiumShortage)}
${renderMatrixRow('high', '상급 PC (70점 ~ 85점)', '#1E8E7C', highShortage)}
${renderMatrixRow('normal', '중급 PC (40점 ~ 70점)', '#10B981', normalShortage)}
${renderMatrixRow('entry', '보급 PC (20점 ~ 40점)', '#F59E0B', entryShortage)}
${renderMatrixRow('replace', '교체 대상 PC (20점 미만 또는 Win11 불가)', '#EF4444', replaceShortage)}
<tr style="background: #F8FAFC; border-top: 2px solid #E2E8F0; font-weight: 800;">
<td style="padding: 14px 12px; color: #1E293B; font-weight: 800; font-size: 1.25rem;">합계 (Total)</td>
<td class="matrix-cell" data-grade="all" data-type="total" style="${cellStyleHeader}" ${hoverEventsHeader}>${totalPcs}대 <span style="font-size:1.125rem; color:#64748B; font-weight:600;">(100%)</span></td>
<td class="matrix-cell" data-grade="all" data-type="active" style="${cellStyleHeader}" ${hoverEventsHeader}>${totalActive}대</td>
<td class="matrix-cell" data-grade="all" data-type="stock" style="${cellStyleHeader}" ${hoverEventsHeader}>${totalStock}대</td>
<td class="matrix-cell" data-grade="all" data-type="under" style="${cellStyleHeader} color: #EF4444;" ${hoverEventsHeader}>${totalShortage}대</td>
</tr>
`;
// 셀별 동적 클릭 리스너 바인딩
matrixTbody.querySelectorAll('.matrix-cell').forEach(cell => {
cell.addEventListener('click', () => {
const grade = cell.getAttribute('data-grade')!;
const type = cell.getAttribute('data-type')!;
let targetList: any[] = [];
let title = '';
const getGradeLabel = (g: string) => {
if (g === 'premium') return '최상급 PC';
if (g === 'high') return '상급 PC';
if (g === 'normal') return '중급 PC';
if (g === 'entry') return '보급 PC';
if (g === 'replace') return '교체 대상 PC';
return '전체 PC';
};
const getTypeLabel = (t: string) => {
if (t === 'total') return '보유';
if (t === 'active') return '운영중';
if (t === 'stock') return '재고';
if (t === 'under') return '구매 필요';
return '';
};
if (grade === 'all') {
if (type === 'total') {
targetList = filtered;
} else if (type === 'active') {
targetList = filtered.filter(p => !isStock(p));
} else if (type === 'stock') {
targetList = filtered.filter(p => isStock(p));
} else if (type === 'under') {
targetList = criticalList.filter(p => p._spec_status === '사양 부족');
}
} else {
const data = matrix[grade as keyof typeof matrix];
if (type === 'total') {
targetList = data.pcs;
} else if (type === 'active') {
targetList = data.activePcs;
} else if (type === 'stock') {
targetList = data.stockPcs;
} else if (type === 'under') {
targetList = data.underPcs;
}
}
title = `${getGradeLabel(grade)} - ${getTypeLabel(type)} 자산 목록`;
showMiniListModal(title, targetList);
});
});
// 7. 연도별 PC 노후도 집계 및 렌더링 // 7. 연도별 PC 노후도 집계 및 렌더링
const agingCounts = { const agingCounts = {
@@ -535,9 +442,9 @@ function updateDashboardData(pcs: any[], selectedDept: string) {
const renderAgingRow = (label: string, list: any[], badgeText: string, badgeStyle: string, ageGroupKey: string) => { const renderAgingRow = (label: string, list: any[], badgeText: string, badgeStyle: string, ageGroupKey: string) => {
return ` return `
<tr style="border-bottom:1px solid #F1F5F9; cursor:pointer; transition: background 0.2s;" class="aging-row" data-group="${ageGroupKey}" onmouseover="this.style.background='#F8FAFC'" onmouseout="this.style.background='none'"> <tr style="border-bottom:1px solid #F1F5F9; cursor:pointer; transition: background 0.2s;" class="aging-row" data-group="${ageGroupKey}" onmouseover="this.style.background='#F8FAFC'" onmouseout="this.style.background='none'">
<td style="padding:10px 10px; font-weight:700; color:#334155; font-size: 1.15rem;">${label}</td> <td style="padding:14px 4px; font-weight:700; color:#334155;">${label}</td>
<td style="padding:10px 10px; text-align:center; font-weight:700; color:#334155; font-size: 1.15rem;">${list.length}대</td> <td style="padding:14px 4px; text-align:center; font-weight:700; color:#334155;">${list.length}대</td>
<td style="padding:10px 10px; text-align:center;"> <td style="padding:14px 4px; text-align:center;">
<span style="padding:2px 8px; border-radius:4px; font-size:14px; font-weight:800; ${badgeStyle}">${badgeText}</span> <span style="padding:2px 8px; border-radius:4px; font-size:14px; font-weight:800; ${badgeStyle}">${badgeText}</span>
</td> </td>
</tr> </tr>
@@ -565,7 +472,7 @@ function updateDashboardData(pcs: any[], selectedDept: string) {
}); });
}); });
// 8. 요약 지표 카드 클릭 리스너 설정 // 8. 각 등급 행 클릭 리스너 설정
const bindCardClick = (id: string, gradeTitle: string, filterFn: (p: any) => boolean) => { const bindCardClick = (id: string, gradeTitle: string, filterFn: (p: any) => boolean) => {
const card = document.getElementById(id)!; const card = document.getElementById(id)!;
if (!card) return; if (!card) return;
@@ -581,11 +488,23 @@ function updateDashboardData(pcs: any[], selectedDept: string) {
}; };
}; };
// 사양 부족 / 오버 스펙 / 윈도우 11 불가 클릭 리스너 설정 bindCardClick('grade-premium', '최상급 PC', p => p._pc_score >= 85);
bindCardClick('card-under-spec', '사양 부족 대상', p => p._spec_status === '사양 부족'); bindCardClick('grade-high', '상급 PC', p => p._pc_score >= 70 && p._pc_score < 85);
bindCardClick('card-over-spec', '오버 스펙 대상', p => p._spec_status === '오버스펙'); bindCardClick('grade-normal', '중급 PC', p => p._pc_score >= 40 && p._pc_score < 70);
bindCardClick('grade-entry', '보급 PC', p => p._pc_score < 40);
// 사양 부족 / 오버스펙 / 윈도우 11 불가 클릭 리스너 설정
bindCardClick('card-under-spec', '사양 부족 검토 대상', p => p._spec_status === '사양 부족');
bindCardClick('card-over-spec', '오버스펙 검토 대상', p => p._spec_status === '오버스펙');
bindCardClick('card-win11-incompatible', '윈도우 11 업그레이드 불가 PC', p => isWindows11Incompatible(p.cpu, p.ram)); bindCardClick('card-win11-incompatible', '윈도우 11 업그레이드 불가 PC', p => isWindows11Incompatible(p.cpu, p.ram));
// 8.2 유효 재고 현황 클릭 리스너 설정
bindCardClick('stock-premium-card', '최상급 유효 재고', p => p._pc_score >= 85 && isStock(p));
bindCardClick('stock-high-card', '상급 유효 재고', p => p._pc_score >= 70 && p._pc_score < 85 && isStock(p));
bindCardClick('stock-normal-card', '중급 유효 재고', p => p._pc_score >= 40 && p._pc_score < 70 && isStock(p));
bindCardClick('stock-entry-card', '보급 유효 재고', p => p._pc_score < 40 && isStock(p));
// 9. 직무별 사양 적정성 대수 연산 및 차트 데이터 셋 구성 (누적 막대 그래프화) // 9. 직무별 사양 적정성 대수 연산 및 차트 데이터 셋 구성 (누적 막대 그래프화)
const activeJobs = Array.from( const activeJobs = Array.from(
new Set(filtered.map((p: any) => p[ASSET_SCHEMA.USER_POSITION.key] || '미분류').filter(j => j !== '재고PC')) new Set(filtered.map((p: any) => p[ASSET_SCHEMA.USER_POSITION.key] || '미분류').filter(j => j !== '재고PC'))
@@ -626,7 +545,7 @@ function updateDashboardData(pcs: any[], selectedDept: string) {
// 10. 차트들 렌더링 호출 // 10. 차트들 렌더링 호출
renderChart(activeJobs, underData, normalData, overData, filtered); renderChart(activeJobs, underData, normalData, overData, filtered);
renderDonutChart(matrix.premium.total, matrix.high.total, matrix.normal.total, matrix.entry.total, matrix.replace.total); renderDonutChart(gradeCounts.premium.total, gradeCounts.high.total, gradeCounts.normal.total, gradeCounts.entry.total);
// 전역 상태 등록 // 전역 상태 등록
state.activeCharts = [jobChartInstance, donutChartInstance]; state.activeCharts = [jobChartInstance, donutChartInstance];
@@ -658,7 +577,7 @@ function showMiniListModal(title: string, list: any[]) {
`; `;
modal.innerHTML = ` modal.innerHTML = `
<div style="background: white; border-radius: 12px; width: 800px; max-width: 95%; max-height: 80%; display: flex; flex-direction: column; box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15); overflow: hidden; border: 1px solid #E2E8F0; animation: modalFadeIn 0.2s ease-out; color: #1E293B;"> <div style="background: white; border-radius: 12px; width: 680px; max-width: 90%; max-height: 80%; display: flex; flex-direction: column; box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15); overflow: hidden; border: 1px solid #E2E8F0; animation: modalFadeIn 0.2s ease-out; color: #1E293B;">
<div style="padding: 1.25rem 1.75rem; border-bottom: 1px solid #F1F5F9; display: flex; justify-content: space-between; align-items: center; background: #F8FAFC;"> <div style="padding: 1.25rem 1.75rem; border-bottom: 1px solid #F1F5F9; display: flex; justify-content: space-between; align-items: center; background: #F8FAFC;">
<h3 style="margin: 0; font-size: 1.26rem; font-weight: 850; color: #1E5149; display: flex; align-items: center; gap: 0.5rem;"> <h3 style="margin: 0; font-size: 1.26rem; font-weight: 850; color: #1E5149; display: flex; align-items: center; gap: 0.5rem;">
<span style="display:inline-block; width:8px; height:8px; border-radius:50%; background:#1E5149;"></span> <span style="display:inline-block; width:8px; height:8px; border-radius:50%; background:#1E5149;"></span>
@@ -673,31 +592,23 @@ function showMiniListModal(title: string, list: any[]) {
<table style="width: 100%; border-collapse: collapse; text-align: left; font-size: 1.01rem; table-layout: fixed;"> <table style="width: 100%; border-collapse: collapse; text-align: left; font-size: 1.01rem; table-layout: fixed;">
<thead style="position: sticky; top: 0; background: white; z-index: 10;"> <thead style="position: sticky; top: 0; background: white; z-index: 10;">
<tr style="border-bottom: 2px solid #E2E8F0; color: #64748B; font-weight: 800; background: white;"> <tr style="border-bottom: 2px solid #E2E8F0; color: #64748B; font-weight: 800; background: white;">
<th style="padding: 10px 4px; width: 14%; background: white;">사용자</th> <th style="padding: 10px 4px; width: 18%; background: white;">사용자</th>
<th style="padding: 10px 4px; width: 25%; background: white;">조직 (직무)</th> <th style="padding: 10px 4px; width: 35%; background: white;">조직 (직무)</th>
<th style="padding: 10px 4px; width: 28%; background: white;">주요 사양</th> <th style="padding: 10px 4px; width: 30%; background: white;">주요 사양</th>
<th style="padding: 10px 4px; width: 18%; text-align: center; background: white;">등급 (점수)</th>
<th style="padding: 10px 4px; text-align: center; background: white;">자산코드</th> <th style="padding: 10px 4px; text-align: center; background: white;">자산코드</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
${list.length === 0 ${list.length === 0
? `<tr><td colspan="5" style="text-align:center; padding:3rem; color:#94A3B8; font-weight:500;">해당 등급의 자산이 없습니다.</td></tr>` ? `<tr><td colspan="4" style="text-align:center; padding:3rem; color:#94A3B8; font-weight:500;">해당 등급의 자산이 없습니다.</td></tr>`
: list.map(pc => { : list.map(pc => {
const spec = `${pc.cpu || ''} / ${pc.ram || ''} / ${pc.gpu || '-'}`; const spec = `${pc.cpu || ''} / ${pc.ram || ''} / ${pc.gpu || '-'}`;
const user = pc.user_current || '(재고)'; const user = pc.user_current || '(재고)';
const score = pc._pc_score !== undefined ? pc._pc_score : calculatePcScoreDeductive(pc.cpu, pc.ram, pc.gpu, pc.purchase_date);
const win11Incompatible = isWindows11Incompatible(pc.cpu, pc.ram);
const grade = getPcGrade(score, win11Incompatible);
const badgeHTML = `<span class="badge ${grade.class}" style="font-size: 11px; padding: 2px 6px;">${grade.name}</span>`;
const scoreHTML = `<strong style="color: ${grade.color}; font-size: 13px; margin-left: 4px;">${score}점</strong>`;
return ` return `
<tr style="border-bottom: 1px solid #F1F5F9; cursor: pointer; transition: background 0.2s;" class="mini-modal-row" data-id="${pc.id}" onmouseover="this.style.background='#F8FAFC'" onmouseout="this.style.background='none'"> <tr style="border-bottom: 1px solid #F1F5F9; cursor: pointer; transition: background 0.2s;" class="mini-modal-row" data-id="${pc.id}" onmouseover="this.style.background='#F8FAFC'" onmouseout="this.style.background='none'">
<td style="padding: 12px 4px; font-weight: 700; color: #334155; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${user}">${user}</td> <td style="padding: 12px 4px; font-weight: 700; color: #334155; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${user}">${user}</td>
<td style="padding: 12px 4px; color: #475569; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${pc.current_dept || '-'} (${pc.user_position || '-'})">${pc.current_dept || '-'} (${pc.user_position || '-'})</td> <td style="padding: 12px 4px; color: #475569; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${pc.current_dept || '-'} (${pc.user_position || '-'})">${pc.current_dept || '-'} (${pc.user_position || '-'})</td>
<td style="padding: 12px 4px; color: #64748B; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${spec}">${spec}</td> <td style="padding: 12px 4px; color: #64748B; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${spec}">${spec}</td>
<td style="padding: 12px 4px; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">${badgeHTML}${scoreHTML}</td>
<td style="padding: 12px 4px; font-family: monospace; color: #475569; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${pc.asset_code || '-'}">${pc.asset_code || '-'}</td> <td style="padding: 12px 4px; font-family: monospace; color: #475569; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${pc.asset_code || '-'}">${pc.asset_code || '-'}</td>
</tr> </tr>
`; `;
@@ -784,7 +695,7 @@ function renderChart(labels: string[], underData: number[], normalData: number[]
categoryPercentage: 0.8 categoryPercentage: 0.8
}, },
{ {
label: '오버 스펙', label: '오버스펙',
data: overData, data: overData,
backgroundColor: 'rgba(217, 119, 6, 0.85)', // Amber Orange backgroundColor: 'rgba(217, 119, 6, 0.85)', // Amber Orange
borderColor: 'rgb(217, 119, 6)', borderColor: 'rgb(217, 119, 6)',
@@ -828,7 +739,7 @@ function renderChart(labels: string[], underData: number[], normalData: number[]
return specStatus === clickedStatus; return specStatus === clickedStatus;
}); });
showMiniListModal(`${clickedJob} - ${clickedStatus === '적정' ? '적정 사양' : (clickedStatus === '오버스펙' ? '오버 스펙' : clickedStatus)} 자산`, matchedPcs); showMiniListModal(`${clickedJob} - ${clickedStatus === '적정' ? '적정 사양' : clickedStatus} 자산`, matchedPcs);
} }
}, },
plugins: { plugins: {
@@ -836,10 +747,10 @@ function renderChart(labels: string[], underData: number[], normalData: number[]
position: 'top', position: 'top',
align: 'end', align: 'end',
labels: { labels: {
font: { family: 'Pretendard', size: 16, weight: '700' }, font: { family: 'Pretendard', size: 11, weight: '700' },
color: '#475569', color: '#475569',
boxWidth: 12, boxWidth: 8,
boxHeight: 12, boxHeight: 8,
usePointStyle: true usePointStyle: true
} }
}, },
@@ -862,7 +773,7 @@ function renderChart(labels: string[], underData: number[], normalData: number[]
stacked: true, stacked: true,
ticks: { ticks: {
callback: (val: any) => `${val}`, callback: (val: any) => `${val}`,
font: { family: 'Pretendard', size: 14, weight: '600' }, font: { family: 'Pretendard', size: 10, weight: '600' },
color: '#64748B' color: '#64748B'
}, },
grid: { color: '#EEF2F6' } grid: { color: '#EEF2F6' }
@@ -870,7 +781,7 @@ function renderChart(labels: string[], underData: number[], normalData: number[]
y: { y: {
stacked: true, stacked: true,
ticks: { ticks: {
font: { family: 'Pretendard', size: 16, weight: '700' }, font: { family: 'Pretendard', size: 11, weight: '700' },
color: '#475569' color: '#475569'
}, },
grid: { display: false } grid: { display: false }
@@ -883,7 +794,7 @@ function renderChart(labels: string[], underData: number[], normalData: number[]
/** /**
* 실시간 사양 적정률 원형 도넛 그래프 (Active Spec Rate) * 실시간 사양 적정률 원형 도넛 그래프 (Active Spec Rate)
*/ */
function renderDonutChart(premium: number, high: number, normal: number, entry: number, replace: number) { function renderDonutChart(premium: number, high: number, normal: number, entry: number) {
const ctx = document.getElementById('chart-overall-donut') as HTMLCanvasElement; const ctx = document.getElementById('chart-overall-donut') as HTMLCanvasElement;
if (!ctx || typeof Chart === 'undefined') return; if (!ctx || typeof Chart === 'undefined') return;
@@ -892,20 +803,19 @@ function renderDonutChart(premium: number, high: number, normal: number, entry:
donutChartInstance = null; donutChartInstance = null;
} }
const total = premium + high + normal + entry + replace; const total = premium + high + normal + entry;
donutChartInstance = new Chart(ctx, { donutChartInstance = new Chart(ctx, {
type: 'doughnut', type: 'doughnut',
data: { data: {
labels: ['최상급', '상급', '중급', '보급', '교체 대상'], labels: ['최상급', '상급', '중급', '보급'],
datasets: [{ datasets: [{
data: [premium, high, normal, entry, replace], data: [premium, high, normal, entry],
backgroundColor: [ backgroundColor: [
'#11302B', // premium (Hanmac Dark Green) '#11302B', // premium (Hanmac Dark Green)
'#1E8E7C', // high (Hanmac Teal) '#1E8E7C', // high (Hanmac Teal)
'#10B981', // normal (Hanmac Mint) '#10B981', // normal (Hanmac Mint)
'#F59E0B', // entry (Yellow-Orange) '#94A3B8' // entry (Slate Gray)
'#EF4444' // replace (Red)
], ],
borderColor: '#ffffff', borderColor: '#ffffff',
borderWidth: 2 borderWidth: 2
@@ -939,7 +849,7 @@ function renderDonutChart(premium: number, high: number, normal: number, entry:
top: 50%; top: 50%;
left: 50%; left: 50%;
transform: translate(-50%, -46%); transform: translate(-50%, -46%);
font-size: 1.65rem; font-size: 1.56rem;
font-weight: 900; font-weight: 900;
color: #1E5149; color: #1E5149;
font-family: 'Pretendard', sans-serif; font-family: 'Pretendard', sans-serif;

View File

@@ -1,5 +1,5 @@
import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema'; import { ASSET_SCHEMA, UI_TEXT } from '../../core/schema';
import { dynamicSort, renderPageHeader, calculateAssetAge, formatInline, isWindows11Incompatible } from '../../core/utils'; import { dynamicSort, renderPageHeader, calculateAssetAge } from '../../core/utils';
import { setupTableSorting, SortState } from '../../core/tableHandler'; import { setupTableSorting, SortState } from '../../core/tableHandler';
import { renderFilterBar, applyCommonFilters } from '../../core/filterHandler'; import { renderFilterBar, applyCommonFilters } from '../../core/filterHandler';
import { state } from '../../core/state'; import { state } from '../../core/state';
@@ -177,22 +177,22 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
} }
let currentFilters: any = (state as any).listFilters[filterKey]; let currentFilters: any = (state as any).listFilters[filterKey];
// 서버 탭이 아닐 경우 '자산 현황' 뷰 진입 방지 및 강제 'asset' 모드 (PC 탭은 자산 현황 숨김) // 서버 및 PC 탭이 아닐 경우 '자산 현황' 뷰 진입 방지 및 강제 'asset' 모드
const isServer = config.title === '서버'; const isServerOrPc = config.title === '서버' || config.title === 'PC';
if (!isServer) { if (!isServerOrPc) {
(state as any).currentViewMode = 'asset'; (state as any).currentViewMode = 'asset';
} else if (!(state as any).currentViewMode) { } else if (!(state as any).currentViewMode) {
(state as any).currentViewMode = 'system'; (state as any).currentViewMode = 'system';
} }
// 2. 뷰 전환 토글 버튼 생성 (명칭 변경) // 2. 뷰 전환 토글 버튼 생성
const toggleWrapper = document.createElement('div'); const toggleWrapper = document.createElement('div');
toggleWrapper.className = 'view-toggle-container'; toggleWrapper.className = 'view-toggle-container';
const showPcFlowBtn = config.title === 'PC'; const showPcFlowBtn = config.title === 'PC';
toggleWrapper.innerHTML = ` toggleWrapper.innerHTML = `
<div style="display: flex; justify-content: space-between; align-items: center; width: 100%;"> <div style="display: flex; justify-content: space-between; align-items: center; width: 100%;">
<div class="view-toggle" style="display: ${isServer ? 'flex' : 'none'}; gap: 0;"> <div class="view-toggle" style="display: ${isServerOrPc ? 'flex' : 'none'}; gap: 0;">
<button class="toggle-btn ${(state as any).currentViewMode === 'system' ? 'active' : ''}" data-mode="system">자산 현황</button> <button class="toggle-btn ${(state as any).currentViewMode === 'system' ? 'active' : ''}" data-mode="system">자산 현황</button>
<button class="toggle-btn ${(state as any).currentViewMode === 'asset' ? 'active' : ''}" data-mode="asset">자산 목록</button> <button class="toggle-btn ${(state as any).currentViewMode === 'asset' ? 'active' : ''}" data-mode="asset">자산 목록</button>
</div> </div>
@@ -228,7 +228,6 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
let selectedDetailLocation: string | null = null; let selectedDetailLocation: string | null = null;
let dynamicMapConfig: Record<string, any[]> = {}; let dynamicMapConfig: Record<string, any[]> = {};
// 맵 설정 미리 로드
const fetchMapConfig = async () => { const fetchMapConfig = async () => {
try { try {
const res = await fetch(`http://${location.hostname}:3000/api/maps`); const res = await fetch(`http://${location.hostname}:3000/api/maps`);
@@ -273,41 +272,23 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
typeLocMap: {} as Record<string, Record<string, number>> typeLocMap: {} as Record<string, Record<string, number>>
}; };
// 중앙화된 경고 감지 로직
const checkAnomaly = (serviceType: string, loc: string, type: string) => { const checkAnomaly = (serviceType: string, loc: string, type: string) => {
if (serviceType !== '외부') return { isWarning: false, isLocWarning: false, isTypeWarning: false, reason: '' }; if (serviceType !== '외부') return { isWarning: false, isLocWarning: false, isTypeWarning: false };
const isLocWarning = loc !== 'IDC' && loc !== '미지정' && loc !== ''; const isLocWarning = loc !== 'IDC' && loc !== '미지정' && loc !== '';
const isTypeWarning = type.toLowerCase().replace(/\s/g, '').includes('서버pc'); const isTypeWarning = type.toLowerCase().replace(/\s/g, '').includes('서버pc');
const isWarning = isLocWarning || isTypeWarning; return { isWarning: isLocWarning || isTypeWarning, isLocWarning, isTypeWarning };
let reason = '';
if (isLocWarning && isTypeWarning) reason = '위치/형식 부적절';
else if (isLocWarning) reason = '위치 부적절';
else if (isTypeWarning) reason = '형식 부적절';
return { isWarning, isLocWarning, isTypeWarning, reason };
}; };
fullList.forEach(asset => { fullList.forEach(asset => {
const loc = asset[ASSET_SCHEMA.LOCATION.key] || '미지정'; const loc = asset[ASSET_SCHEMA.LOCATION.key] || '미지정';
const serviceTypeKey = (ASSET_SCHEMA as any).SERVICE_TYPE?.key || 'service_type'; const serviceType = asset.service_type || '외부';
const serviceType = asset[serviceTypeKey] || '외부';
const type = asset[ASSET_SCHEMA.ASSET_TYPE.key] || ''; const type = asset[ASSET_SCHEMA.ASSET_TYPE.key] || '';
locationCounts[loc] = (locationCounts[loc] || 0) + 1;
if (isPcView) {
if (type.includes('공용')) pcTypeCounts.public++;
else if (type.includes('서버')) pcTypeCounts.server++;
else pcTypeCounts.personal++;
}
const targetStat = serviceType === '내부' ? intStats : extStats; const targetStat = serviceType === '내부' ? intStats : extStats;
targetStat.total++; targetStat.total++;
if (loc) targetStat.locCounts[loc] = (targetStat.locCounts[loc] || 0) + 1; if (loc) targetStat.locCounts[loc] = (targetStat.locCounts[loc] || 0) + 1;
if (type) { if (type) {
targetStat.typeCounts[type] = (targetStat.typeCounts[type] || 0) + 1; targetStat.typeCounts[type] = (targetStat.typeCounts[type] || 0) + 1;
// 유형별 위치 분포 수집
if (!targetStat.typeLocMap[type]) targetStat.typeLocMap[type] = {}; if (!targetStat.typeLocMap[type]) targetStat.typeLocMap[type] = {};
targetStat.typeLocMap[type][loc] = (targetStat.typeLocMap[type][loc] || 0) + 1; targetStat.typeLocMap[type][loc] = (targetStat.typeLocMap[type][loc] || 0) + 1;
} }
@@ -319,64 +300,41 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
} }
}); });
// 템플릿 제너레이터 함수 (HTML 중복 제거)
const generateDetailStatHTML = (title: string, stats: any) => ` const generateDetailStatHTML = (title: string, stats: any) => `
<div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; gap: 0.5rem;"> <div class="detail-stat-header">
<span style="font-size: 14px; font-weight: 800; color: var(--text-main); white-space: nowrap;">${title}</span> <span class="stat-title">${title}</span>
<div style="display: flex; gap: 4px; flex-wrap: wrap; justify-content: flex-end;"> <div class="stat-badges">
${stats.locWarning ? `<span style="background: #FFF7ED; color: #C2410C; font-size: 10px; font-weight: 800; padding: 2px 6px; border-radius: 4px; border: 1px solid #FFEDD5; white-space: nowrap;">위치부적절: ${stats.locWarning}</span>` : ''} ${stats.locWarning ? `<span class="warning-badge-orange">위치부적절: ${stats.locWarning}</span>` : ''}
${stats.typeWarning ? `<span style="background: #FFF1F2; color: #E11D48; font-size: 10px; font-weight: 800; padding: 2px 6px; border-radius: 4px; border: 1px solid #FDA4AF; white-space: nowrap;">형식부적절: ${stats.typeWarning}</span>` : ''} ${stats.typeWarning ? `<span class="warning-badge">형식부적절: ${stats.typeWarning}</span>` : ''}
</div> </div>
</div> </div>
<div style="display: flex; flex-direction: column; gap: 0.3rem; font-size: 13px; color: var(--text-muted);"> <div class="detail-stat-body">
<div style="display: flex; gap: 0.75rem; flex-wrap: wrap;"> <div class="loc-summary">
${Object.entries(stats.locCounts as Record<string, number>).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([l, c]) => `<span>${l}: <strong style="color:var(--text-main); font-size: 14px;">${c}</strong></span>`).join('')} ${Object.entries(stats.locCounts as Record<string, number>).sort((a, b) => b[1] - a[1]).slice(0, 4).map(([l, c]) => `<span>${l}: <strong>${c}</strong></span>`).join('')}
</div> </div>
<div style="display: flex; gap: 0.6rem; flex-wrap: wrap; opacity: 0.9; border-top: 1px dashed var(--border-color); padding-top: 4px; margin-top: 2px;"> <div class="type-summary">
${Object.entries(stats.typeCounts as Record<string, number>).sort((a, b) => b[1] - a[1]).slice(0, 6).map(([t, c]) => { ${Object.entries(stats.typeCounts as Record<string, number>).sort((a, b) => b[1] - a[1]).slice(0, 6).map(([t, c]) => {
const isTypeWarning = title.includes('외부') && t.toLowerCase().replace(/\s/g, '').includes('서버pc');
// 위치별 상세 정보 생성 (툴팁용)
const locDist = stats.typeLocMap[t] || {}; const locDist = stats.typeLocMap[t] || {};
const locHint = Object.entries(locDist) const locHint = Object.entries(locDist).sort((a: any, b: any) => b[1] - a[1]).map(([l, count]) => `${l}: ${count}`).join('\n');
.sort((a: any, b: any) => b[1] - a[1]) return `<span title="${locHint}">${t}: <strong>${c}</strong></span>`;
.map(([l, count]) => `${l}: ${count}`)
.join('\n');
return `<span title="${locHint}" style="${isTypeWarning ? 'color:#E11D48; font-weight:700;' : ''}; font-size: 13px; cursor: help;">${t}: <strong style="color:var(--text-main); font-size: 14px;">${c}</strong></span>`;
}).join('')} }).join('')}
</div> </div>
</div> </div>
`; `;
contentWrapper.innerHTML = ` contentWrapper.innerHTML = `
<div class="system-dashboard" style="height: calc(100vh - 240px); overflow: hidden; padding: 0.5rem 0; font-family: 'Pretendard', sans-serif; letter-spacing: -0.02em; display: flex; flex-direction: column;"> <div class="system-dashboard">
<div class="dashboard-stats-row">
<!-- [자산 통계 그룹] --> <div class="stat-group-item">
<div style="border-bottom: 1px solid var(--border-color); padding-bottom: 1.25rem; margin-bottom: 1rem; flex-shrink: 0; display: grid; grid-template-columns: 1fr 1.5fr 1.5fr; gap: 2rem;"> <div class="stat-label">총 보유 자산</div>
<div class="stat-group-item" style="min-width: 0;"> <div class="stat-value">${fullList.length}<span>대</span></div>
<div style="font-size: 11px; font-weight: 600; color: var(--text-muted); margin-bottom: 0.25rem;">총 보유 자산</div> <div class="stat-sub">
<div style="font-size: 28px; font-weight: 800; color: var(--text-main); line-height: 1.1;">${fullList.length}<span style="font-size: 13px; font-weight: 600; margin-left: 4px; color: var(--text-muted);">대</span></div> <span>외부: <strong class="text-primary">${extStats.total}</strong></span>
<div style="display: flex; gap: 0.75rem; font-size: 14px; color: var(--text-muted); margin-top: 0.5rem;"> <span>내부: <strong class="text-muted">${intStats.total}</strong></span>
<span>외부: <strong style="color:#35635C; font-size: 18px;">${extStats.total}</strong></span>
<span>내부: <strong style="color:#94A3B8; font-size: 18px;">${intStats.total}</strong></span>
</div> </div>
</div> </div>
<div class="stat-group-item bordered">${generateDetailStatHTML('외부 (운영) 상세', extStats)}</div>
<div class="stat-group-item" style="border-left: 1px solid var(--border-color); padding-left: 1.5rem; min-width: 0;"> <div class="stat-group-item bordered">${generateDetailStatHTML('내부 (테스트) 상세', intStats)}</div>
${isPcView ? `
<div style="font-size: 11px; font-weight: 600; color: var(--text-muted); margin-bottom: 0.25rem;">PC 유형별 현황</div>
<div style="display: flex; gap: 1rem; font-size: 14px; color: var(--text-muted); margin-top: 0.5rem;">
<span>공용: <strong style="color:var(--text-main); font-size: 18px;">${pcTypeCounts.public}</strong></span>
<span>서버: <strong style="color:var(--text-main); font-size: 18px;">${pcTypeCounts.server}</strong></span>
<span>개인: <strong style="color:var(--text-main); font-size: 18px;">${pcTypeCounts.personal}</strong></span>
</div>
` : generateDetailStatHTML('외부 (운영) 상세', extStats)}
</div>
<div class="stat-group-item" style="border-left: 1px solid var(--border-color); padding-left: 1.5rem; min-width: 0;">
${isPcView ? '' : generateDetailStatHTML('내부 (테스트) 상세', intStats as any)}
</div>
</div> </div>
<div style="display: flex; flex: 1; min-height: 0; border-top: 1px solid var(--border-color);"> <div style="display: flex; flex: 1; min-height: 0; border-top: 1px solid var(--border-color);">
@@ -393,8 +351,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
<option value="">전체</option> <option value="">전체</option>
${validLocations.map(l => `<option value="${l}" ${l === selectedLocation ? 'selected' : ''}>${l}</option>`).join('')} ${validLocations.map(l => `<option value="${l}" ${l === selectedLocation ? 'selected' : ''}>${l}</option>`).join('')}
</select> </select>
<span style="font-size: 11px; font-weight: 600; color: var(--text-muted);">상세:</span> <select id="select-detail-loc" class="filter-select select-detail-loc"></select>
<select id="select-detail-loc" style="padding: 2px 8px; font-size: 11px; border-radius: 4px; border: 1px solid var(--border-color); outline: none; background: white; cursor:pointer; font-family: 'Pretendard'; max-width: 120px;"></select>
</div> </div>
` : ''} ` : ''}
</div> </div>
@@ -421,7 +378,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
</tr> </tr>
`} `}
</thead> </thead>
<tbody id="system-status-tbody" style="font-size: 12px;"></tbody> <tbody id="system-status-tbody"></tbody>
</table> </table>
</div> </div>
</div> </div>
@@ -456,22 +413,12 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
<p style="font-size: 1.125rem; font-weight: 500; color: #94A3B8;">목록에서 자산을 선택하면<br>상세 정보와 배치도가 표시됩니다.</p> <p style="font-size: 1.125rem; font-weight: 500; color: #94A3B8;">목록에서 자산을 선택하면<br>상세 정보와 배치도가 표시됩니다.</p>
`} `}
</div> </div>
<div id="detail-content" style="display: none; height: 100%; flex-direction: column;"> <div id="detail-content" class="detail-content hidden">
<!-- 상단 요약 정보 (Wrapping 방지 최적화) --> <div class="detail-summary-header">
<div style="display: flex; justify-content: space-between; align-items: flex-end; margin-bottom: 1.5rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border-color); flex-shrink: 0; gap: 2rem;"> <div class="summary-items">
<div style="display: flex; gap: 2.5rem; align-items: flex-end; min-width: 0; flex: 1;"> <div class="summary-item"><label>자산번호</label><div id="detail-asset-code" class="code-value"></div></div>
<div style="flex-shrink: 0;"> <div class="summary-item"><label>유형</label><div id="detail-asset-type" class="type-value"></div></div>
<label style="display: block; font-size: 10px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; margin-bottom: 4px;">자산번호</label> <div class="summary-item flex-1"><label>메모 요약</label><div id="detail-memo" class="memo-value"></div></div>
<div id="detail-asset-code" style="font-size: 14px; font-weight: 800; color: var(--primary-color); white-space: nowrap;"></div>
</div>
<div style="flex-shrink: 0;">
<label style="display: block; font-size: 10px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; margin-bottom: 4px;">유형</label>
<div id="detail-asset-type" style="font-size: 14px; font-weight: 600; color: var(--text-main); white-space: nowrap;"></div>
</div>
<div style="flex: 1; min-width: 0;">
<label style="display: block; font-size: 10px; font-weight: 700; color: var(--text-muted); text-transform: uppercase; margin-bottom: 4px;">메모 요약</label>
<div id="detail-memo" style="font-size: 14px; color: var(--text-main); font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;"></div>
</div>
</div> </div>
<button id="btn-view-flow-logs" style="flex-shrink: 0; padding: 6px 16px; font-size: 12px; font-weight: 700; background: white; color: var(--primary-color); border: 1px solid var(--primary-color); border-radius: 4px; cursor: pointer; transition: opacity 0.2s; margin-right: 8px;"> <button id="btn-view-flow-logs" style="flex-shrink: 0; padding: 6px 16px; font-size: 12px; font-weight: 700; background: white; color: var(--primary-color); border: 1px solid var(--primary-color); border-radius: 4px; cursor: pointer; transition: opacity 0.2s; margin-right: 8px;">
${isPcView ? '목록 보기' : '이력 보기'} ${isPcView ? '목록 보기' : '이력 보기'}
@@ -494,6 +441,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
<div id="detail-no-photo" style="display: none; height: 100%; flex-direction: column; align-items: center; justify-content: center; gap: 1rem;"> <div id="detail-no-photo" style="display: none; height: 100%; flex-direction: column; align-items: center; justify-content: center; gap: 1rem;">
<span style="color: #94A3B8; font-size: 13px; font-weight: 500;">등록된 배치도가 없습니다.</span> <span style="color: #94A3B8; font-size: 13px; font-weight: 500;">등록된 배치도가 없습니다.</span>
</div> </div>
<div id="detail-no-photo" class="no-photo-state hidden"><span>등록된 배치도가 없습니다.</span></div>
</div> </div>
</div> </div>
</div> </div>
@@ -502,16 +450,15 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
</div> </div>
`; `;
// 상세 정보 패널 업데이트 함수
const updateDetailPanel = (asset: any) => { const updateDetailPanel = (asset: any) => {
const emptyState = document.getElementById('detail-empty-state'); const emptyState = document.getElementById('detail-empty-state');
const content = document.getElementById('detail-content'); const content = document.getElementById('detail-content');
if (!emptyState || !content) return; if (!emptyState || !content) return;
emptyState.style.display = 'none'; emptyState.classList.add('hidden');
content.classList.remove('hidden');
content.style.display = 'flex'; content.style.display = 'flex';
// 텍스트 정보 업데이트
const codeEl = document.getElementById('detail-asset-code'); const codeEl = document.getElementById('detail-asset-code');
const typeEl = document.getElementById('detail-asset-type'); const typeEl = document.getElementById('detail-asset-type');
const memoEl = document.getElementById('detail-memo'); const memoEl = document.getElementById('detail-memo');
@@ -520,24 +467,17 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
if (codeEl) codeEl.textContent = asset.asset_code || '미지정'; if (codeEl) codeEl.textContent = asset.asset_code || '미지정';
if (typeEl) typeEl.textContent = asset.asset_type || '-'; if (typeEl) typeEl.textContent = asset.asset_type || '-';
if (memoEl) memoEl.textContent = asset.memo || '-'; if (memoEl) memoEl.textContent = asset.memo || '-';
if (viewBtn) { if (viewBtn) viewBtn.onclick = () => config.onRowClick && config.onRowClick(asset);
viewBtn.onclick = () => config.onRowClick && config.onRowClick(asset);
}
// 위치 및 사진 정보 업데이트
const photo = document.getElementById('detail-photo') as HTMLImageElement; const photo = document.getElementById('detail-photo') as HTMLImageElement;
const marker = document.getElementById('detail-marker'); const marker = document.getElementById('detail-marker');
const overlayLayer = document.getElementById('detail-overlay-layer'); const overlayLayer = document.getElementById('detail-overlay-layer');
const noPhoto = document.getElementById('detail-no-photo'); const noPhoto = document.getElementById('detail-no-photo');
const photoWrapper = document.getElementById('detail-photo-wrapper');
const bldg = asset.location || ''; const bldg = asset.location || '';
const detail = asset.location_detail || ''; const detail = asset.location_detail || '';
// 숫자 0도 유효한 좌표이므로 정확한 체크 필요 const x = asset.loc_x; const y = asset.loc_y;
const x = asset.loc_x; const hasCoords = (x !== null && x !== undefined && x !== '' && x !== 'null');
const y = asset.loc_y;
const hasCoords = (x !== null && x !== undefined && x !== '' && x !== 'null') &&
(y !== null && y !== undefined && y !== '' && y !== 'null');
const savedImg = asset.location_photo || asset.loc_img; const savedImg = asset.location_photo || asset.loc_img;
const locImgs = IMAGE_LOCATIONS[bldg.trim()]?.[detail.trim()] || null; const locImgs = IMAGE_LOCATIONS[bldg.trim()]?.[detail.trim()] || null;
@@ -625,11 +565,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
} }
if (marker) marker.style.display = 'none'; if (marker) marker.style.display = 'none';
if (overlayLayer) overlayLayer.innerHTML = ''; if (overlayLayer) overlayLayer.innerHTML = '';
if (noPhoto) { if (noPhoto) { noPhoto.classList.remove('hidden'); noPhoto.style.display = 'flex'; }
noPhoto.style.display = 'flex';
const msg = noPhoto.querySelector('span');
if (msg) msg.textContent = !hasCoords ? '등록된 위치 좌표 정보가 없습니다.' : '등록된 배치도가 없습니다.';
}
} }
// 이력 보기 버튼 클릭 이벤트 // 이력 보기 버튼 클릭 이벤트
@@ -914,62 +850,30 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
jobScores[job].avg = jobScores[job].count > 0 ? jobScores[job].totalScore / jobScores[job].count : 0; jobScores[job].avg = jobScores[job].count > 0 ? jobScores[job].totalScore / jobScores[job].count : 0;
}); });
// DB 기준 사양 데이터 맵핑 (state.masterData.jobSpecs 이용)
const jobSpecsMap: Record<string, number> = {};
if (state.masterData.jobSpecs) {
state.masterData.jobSpecs.forEach((s: any) => {
jobSpecsMap[s.job_name] = s.min_score;
});
}
// 기준 대비 사양 부족/오버스펙 분류 // 기준 대비 사양 부족/오버스펙 분류
const criticalPcList: any[] = []; const criticalPcList: any[] = [];
pcs.forEach((pc: any) => { pcs.forEach((pc: any) => {
const job = pc[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; const job = pc[ASSET_SCHEMA.USER_POSITION.key] || '미분류';
const score = pc['_pc_score']; const score = pc['_pc_score'];
const standardScore = jobSpecsMap[job] !== undefined ? jobSpecsMap[job] : (jobScores[job]?.avg || 0); const avg = jobScores[job].avg;
const cpu = pc[ASSET_SCHEMA.CPU.key] || ''; if (avg > 0) {
const ram = pc[ASSET_SCHEMA.RAM.key] || ''; if (score < avg * 0.6) {
const win11Incompatible = isWindows11Incompatible(cpu, ram);
let isUnder = false;
if (standardScore > 0) {
if (score < standardScore * 0.6) {
isUnder = true;
pc['_spec_status'] = '사양 부족'; pc['_spec_status'] = '사양 부족';
} else if (score > standardScore * 1.5 && !win11Incompatible) { criticalPcList.push(pc);
} else if (score > avg * 1.5) {
pc['_spec_status'] = '오버스펙'; pc['_spec_status'] = '오버스펙';
criticalPcList.push(pc); criticalPcList.push(pc);
} else if (win11Incompatible) {
isUnder = true;
pc['_spec_status'] = '사양 부족';
} else {
pc['_spec_status'] = '적정';
} }
} else {
if (win11Incompatible) {
isUnder = true;
pc['_spec_status'] = '사양 부족';
} else {
pc['_spec_status'] = '적정';
}
}
if (isUnder) {
criticalPcList.push(pc);
} }
}); });
// 정렬: 기준 점수 대비 사양 부족이 심한 순(비율이 낮은 순)으로 정렬 // 정렬: 직무 평균 대비 사양 부족이 심한 순(비율이 낮은 순)으로 정렬
criticalPcList.sort((a: any, b: any) => { criticalPcList.sort((a: any, b: any) => {
const jobA = a[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; const jobA = a[ASSET_SCHEMA.USER_POSITION.key] || '미분류';
const jobB = b[ASSET_SCHEMA.USER_POSITION.key] || '미분류'; const jobB = b[ASSET_SCHEMA.USER_POSITION.key] || '미분류';
const stdA = jobSpecsMap[jobA] !== undefined ? jobSpecsMap[jobA] : (jobScores[jobA]?.avg || 0); const ratioA = jobScores[jobA].avg > 0 ? a['_pc_score'] / jobScores[jobA].avg : 1;
const stdB = jobSpecsMap[jobB] !== undefined ? jobSpecsMap[jobB] : (jobScores[jobB]?.avg || 0); const ratioB = jobScores[jobB].avg > 0 ? b['_pc_score'] / jobScores[jobB].avg : 1;
const ratioA = stdA > 0 ? a['_pc_score'] / stdA : 1;
const ratioB = stdB > 0 ? b['_pc_score'] / stdB : 1;
return ratioA - ratioB; return ratioA - ratioB;
}); });
@@ -992,7 +896,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
<td style="padding: 10px 0; font-weight: 600; color: #334155; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${user}">${user}</td> <td style="padding: 10px 0; font-weight: 600; color: #334155; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${user}">${user}</td>
<td style="padding: 10px 0; color: #475569; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${dept} (${job})">${dept} (${job})</td> <td style="padding: 10px 0; color: #475569; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${dept} (${job})">${dept} (${job})</td>
<td style="padding: 10px 0; white-space: nowrap; text-align: center;"> <td style="padding: 10px 0; white-space: nowrap; text-align: center;">
<span style="padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: 700; ${badgeColor}">${status === '오버스펙' ? '오버 스펙' : status}</span> <span style="padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: 700; ${badgeColor}">${status}</span>
</td> </td>
<td style="padding: 10px 0; font-family: monospace; color: #64748B; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${assetCode}">${assetCode}</td> <td style="padding: 10px 0; font-family: monospace; color: #64748B; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;" title="${assetCode}">${assetCode}</td>
</tr> </tr>
@@ -1028,13 +932,6 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
} }
}; };
(window as any).dispatchLocFilter = (loc: string) => {
if (isPcView) return;
selectedLocation = loc;
selectedDetailLocation = null;
renderSystemStatus();
};
setTimeout(() => { setTimeout(() => {
const selectLoc = document.getElementById('select-loc') as HTMLSelectElement; const selectLoc = document.getElementById('select-loc') as HTMLSelectElement;
const selectDetailLoc = document.getElementById('select-detail-loc') as HTMLSelectElement; const selectDetailLoc = document.getElementById('select-detail-loc') as HTMLSelectElement;
@@ -1055,66 +952,37 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
}, 50); }, 50);
}; };
// [자산 목록] 테이블 렌더러
const tableWrapper = document.createElement('div'); const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-container'; tableWrapper.className = 'table-container';
const table = document.createElement('table'); const table = document.createElement('table');
const thead = document.createElement('thead'); const thead = document.createElement('thead');
const tbody = document.createElement('tbody'); const tbody = document.createElement('tbody');
tbody.id = 'dynamic-tbody'; table.appendChild(thead); table.appendChild(tbody);
table.appendChild(thead);
table.appendChild(tbody);
tableWrapper.appendChild(table); tableWrapper.appendChild(table);
const updateTable = () => { const updateTable = () => {
if ((state as any).currentViewMode !== 'asset') return;
let filtered = applyCommonFilters(fullList, currentFilters, config.searchKeys as any[]); let filtered = applyCommonFilters(fullList, currentFilters, config.searchKeys as any[]);
if (sortState.key) filtered = dynamicSort(filtered, sortState.key, sortState.direction); if (sortState.key) filtered = dynamicSort(filtered, sortState.key, sortState.direction);
thead.innerHTML = `<tr>${config.columns.map(col => ` thead.innerHTML = `<tr>${config.columns.map(col => `<th ${col.sortKey ? `data-sort="${col.sortKey}"` : ''} style="${col.width ? `width:${col.width};` : ''}" class="${col.align ? `text-${col.align}` : ''}">${col.header}</th>`).join('')}</tr>`;
<th ${col.sortKey ? `data-sort="${col.sortKey}"` : ''} tbody.innerHTML = filtered.length === 0 ? `<tr><td colspan="${config.columns.length}" class="text-center empty-cell">${UI_TEXT.MESSAGES.NO_DATA}</td></tr>`
style="${col.width ? `width:${col.width};` : ''}${col.align ? `text-align:${col.align};` : ''}" : filtered.map(asset => `<tr class="asset-row clickable" data-id="${asset.id}">${config.columns.map(col => `<td class="${col.align ? `text-${col.align}` : ''}">${col.render(asset)}</td>`).join('')}</tr>`).join('');
class="${col.className || ''}">${col.header}</th>`).join('')}</tr>`;
tbody.innerHTML = filtered.length === 0 tbody.querySelectorAll('.asset-row').forEach((tr, idx) => { tr.addEventListener('click', () => config.onRowClick && config.onRowClick(filtered[idx])); });
? `<tr><td colspan="${config.columns.length}" class="text-center" style="padding: 3rem; color: var(--text-muted);">${config.emptyMessage || UI_TEXT.MESSAGES.NO_DATA}</td></tr>` setupTableSorting(table, sortState, (key, dir) => { sortState = { key, direction: dir }; updateTable(); });
: filtered.map(asset => `
<tr style="cursor:pointer;" class="asset-row" data-id="${asset.id}">
${config.columns.map(col => `<td style="${col.align ? `text-align:${col.align};` : ''}" class="${col.className || ''}">${col.render(asset)}</td>`).join('')}
</tr>`).join('');
tbody.querySelectorAll('.asset-row').forEach((tr, idx) => {
tr.addEventListener('click', () => config.onRowClick && config.onRowClick(filtered[idx]));
});
setupTableSorting(table, sortState, (key, dir) => {
sortState = { key, direction: dir };
if (config.persistentSortState) {
config.persistentSortState.key = key;
config.persistentSortState.direction = dir;
}
updateTable();
});
// createIcons call removed as icons are no longer needed in dashboard
}; };
// --- 뷰 전환 로직 ---
const switchView = () => { const switchView = () => {
contentWrapper.innerHTML = ''; contentWrapper.innerHTML = '';
if ((state as any).currentViewMode === 'asset') { if ((state as any).currentViewMode === 'asset') {
filterBar.style.display = 'flex'; filterBar.style.display = 'flex'; contentWrapper.style.overflowY = 'auto';
contentWrapper.style.overflowY = 'auto'; contentWrapper.appendChild(tableWrapper); updateTable();
contentWrapper.appendChild(tableWrapper);
updateTable();
} else { } else {
filterBar.style.display = 'none'; filterBar.style.display = 'none'; contentWrapper.style.overflowY = 'hidden';
contentWrapper.style.overflowY = 'hidden';
renderSystemStatus(); renderSystemStatus();
} }
}; };
// 토글 버튼 이벤트
toggleWrapper.addEventListener('click', (e) => { toggleWrapper.addEventListener('click', (e) => {
const btn = (e.target as HTMLElement).closest('.toggle-btn') as HTMLButtonElement; const btn = (e.target as HTMLElement).closest('.toggle-btn') as HTMLButtonElement;
if (!btn) return; if (!btn) return;

View File

@@ -1,171 +1,66 @@
import { state } from '../../core/state'; import { state } from '../../core/state';
import { openPartsMasterModal } from '../../components/Modal/PartsMasterModal'; import { openPartsMasterModal } from '../../components/Modal/PartsMasterModal';
import { openJobSpecModal } from '../../components/Modal/JobSpecModal';
import { formatInline } from '../../core/utils'; import { formatInline } from '../../core/utils';
import { createListView } from './ListFactory'; import { createListView } from './ListFactory';
export let activePartsMasterSubTab: 'parts-master' | 'job-spec' = 'parts-master';
export function renderPartsMasterList(container: HTMLElement) { export function renderPartsMasterList(container: HTMLElement) {
if (activePartsMasterSubTab === 'parts-master') { createListView(container, {
createListView(container, { title: '부품 마스터',
title: '부품 마스터', dataSource: () => state.masterData.partsMaster || [],
dataSource: () => state.masterData.partsMaster || [], searchKeys: ['component_name', 'category', 'score_tier'],
searchKeys: ['component_name', 'category', 'score_tier'], filterOptions: {
filterOptions: { keywordLabel: '부품명 / 등급 검색',
keywordLabel: '부품명 / 등급 검색', showLoc: false,
showLoc: false, showDept: false,
showDept: false, showType: false
showType: false },
onRowClick: (component) => openPartsMasterModal(component, 'view'),
columns: [
{
header: 'ID',
sortKey: 'id',
align: 'center',
width: '5%',
render: c => c.id.toString()
}, },
onRowClick: (component) => openPartsMasterModal(component, 'view'), {
columns: [ header: '분류',
{ sortKey: 'category',
header: 'ID', align: 'center',
sortKey: 'id', width: '15%',
align: 'center', render: c => {
width: '5%', let badgeClass = 'badge-primary';
render: c => c.id.toString() if (c.category === 'CPU') badgeClass = 'b-primary';
}, else if (c.category === 'GPU') badgeClass = 'b-purple';
{ else if (c.category === 'RAM') badgeClass = 'b-green';
header: '분류', return `<span class="badge ${badgeClass}">${c.category}</span>`;
sortKey: 'category',
align: 'center',
width: '15%',
render: c => {
let badgeClass = 'badge-primary';
if (c.category === 'CPU') badgeClass = 'b-primary';
else if (c.category === 'GPU') badgeClass = 'b-purple';
else if (c.category === 'RAM') badgeClass = 'b-green';
return `<span class="badge ${badgeClass}">${c.category}</span>`;
}
},
{
header: '부품 표준 명칭',
sortKey: 'component_name',
render: c => formatInline(c.component_name || '-')
},
{
header: '성능 등급',
sortKey: 'score_tier',
align: 'center',
width: '15%',
render: c => c.score_tier || '-'
},
{
header: '감점 점수',
sortKey: 'deduction',
align: 'center',
width: '15%',
render: c => {
const score = c.deduction || 0;
let color = '#3b82f6'; // blue
if (score >= 20) color = '#ef4444'; // red
else if (score >= 10) color = '#f59e0b'; // orange
return `<strong style="color: ${color}; font-size: 14px;">-${score}점</strong>`;
}
} }
]
});
} else {
createListView(container, {
title: '직무별 기준 사양',
dataSource: () => state.masterData.jobSpecs || [],
searchKeys: ['job_name', 'cpu_standard', 'ram_standard', 'gpu_standard', 'remarks'],
filterOptions: {
keywordLabel: '직무명 / 사양 검색',
showLoc: false,
showDept: false,
showType: false
}, },
onRowClick: (jobSpec) => openJobSpecModal(jobSpec, 'view'), {
columns: [ header: '부품 표준 명칭',
{ sortKey: 'component_name',
header: 'ID', render: c => formatInline(c.component_name || '-')
sortKey: 'id', },
align: 'center', {
width: '5%', header: '성능 등급',
render: j => j.id.toString() sortKey: 'score_tier',
}, align: 'center',
{ width: '15%',
header: '직무명', render: c => c.score_tier || '-'
sortKey: 'job_name', },
width: '15%', {
render: j => `<strong style="color: var(--primary-color); font-size: 14px;">${formatInline(j.job_name || '-')}</strong>` header: '감점 점수',
}, sortKey: 'deduction',
{ align: 'center',
header: '권장 CPU 사양', width: '15%',
sortKey: 'cpu_standard', render: c => {
render: j => formatInline(j.cpu_standard || '-') const score = c.deduction || 0;
}, let color = '#3b82f6'; // blue
{ if (score >= 20) color = '#ef4444'; // red
header: '권장 RAM 사양', else if (score >= 10) color = '#f59e0b'; // orange
sortKey: 'ram_standard', return `<strong style="color: ${color}; font-size: 14px;">-${score}점</strong>`;
width: '12%',
render: j => formatInline(j.ram_standard || '-')
},
{
header: '권장 GPU 사양',
sortKey: 'gpu_standard',
render: j => formatInline(j.gpu_standard || '-')
},
{
header: '기준 점수',
sortKey: 'min_score',
align: 'center',
width: '10%',
render: j => `<span style="font-weight: 700;">${j.min_score || 0}점 이상</span>`
},
{
header: '비고',
sortKey: 'remarks',
width: '20%',
render: j => formatInline(j.remarks || '-')
} }
] }
}); ]
}
renderSubTabs(container);
}
function renderSubTabs(container: HTMLElement) {
const header = container.querySelector('.page-header');
if (!header) return;
const tabContainer = document.createElement('div');
tabContainer.className = 'sub-tab-container';
tabContainer.style.cssText = 'display: flex; gap: 16px; margin-top: 16px; margin-bottom: 16px; border-bottom: 1px solid var(--border-color); padding-bottom: 0;';
const tab1Active = activePartsMasterSubTab === 'parts-master';
const tab2Active = activePartsMasterSubTab === 'job-spec';
tabContainer.innerHTML = `
<button id="tab-parts-master" class="sub-tab-btn ${tab1Active ? 'active' : ''}" style="padding: 10px 16px; border: none; background: none; font-size: 14px; font-weight: 600; cursor: pointer; color: ${tab1Active ? 'var(--primary-color)' : 'var(--text-muted)'}; position: relative; border-bottom: 3px solid ${tab1Active ? 'var(--primary-color)' : 'transparent'};">
부품 표준 등급
</button>
<button id="tab-job-spec" class="sub-tab-btn ${tab2Active ? 'active' : ''}" style="padding: 10px 16px; border: none; background: none; font-size: 14px; font-weight: 600; cursor: pointer; color: ${tab2Active ? 'var(--primary-color)' : 'var(--text-muted)'}; position: relative; border-bottom: 3px solid ${tab2Active ? 'var(--primary-color)' : 'transparent'};">
직무별 기준 사양
</button>
`;
header.parentNode!.insertBefore(tabContainer, header.nextSibling);
const tabPartsMaster = tabContainer.querySelector('#tab-parts-master')!;
const tabJobSpec = tabContainer.querySelector('#tab-job-spec')!;
tabPartsMaster.addEventListener('click', () => {
if (activePartsMasterSubTab !== 'parts-master') {
activePartsMasterSubTab = 'parts-master';
renderPartsMasterList(container);
}
});
tabJobSpec.addEventListener('click', () => {
if (activePartsMasterSubTab !== 'job-spec') {
activePartsMasterSubTab = 'job-spec';
renderPartsMasterList(container);
}
}); });
} }

View File

@@ -1,29 +1,18 @@
import { state } from '../../core/state'; import { state } from '../../core/state';
import { openHwModal } from '../../components/Modal/HWModal'; import { openHwModal } from '../../components/Modal/HWModal';
import { sortAssets, formatInline, calculatePcScoreDeductive, getPcGrade, isWindows11Incompatible } from '../../core/utils'; import { sortAssets, formatInline, calculatePcScoreDeductive, getPcGrade } from '../../core/utils';
import { ASSET_SCHEMA } from '../../core/schema'; import { ASSET_SCHEMA } from '../../core/schema';
import { createListView } from './ListFactory'; import { createListView } from './ListFactory';
import { SortState } from '../../core/tableHandler';
let persistentSortState: SortState = { key: 'updated_at', direction: 'desc' };
export function renderPcList(container: HTMLElement) { export function renderPcList(container: HTMLElement) {
createListView(container, { createListView(container, {
title: 'PC', title: 'PC',
persistentSortState,
dataSource: () => { dataSource: () => {
const list = (state.masterData.pc || []).filter((a: any) => a.asset_type !== '서버PC'); const list = (state.masterData.pc || []).filter((a: any) => a.asset_type !== '서버PC');
list.forEach((a: any) => { list.forEach((a: any) => {
a['_pc_score'] = calculatePcScoreDeductive(a[ASSET_SCHEMA.CPU.key], a[ASSET_SCHEMA.RAM.key], a[ASSET_SCHEMA.GPU.key], a.purchase_date); a['_pc_score'] = calculatePcScoreDeductive(a[ASSET_SCHEMA.CPU.key], a[ASSET_SCHEMA.RAM.key], a[ASSET_SCHEMA.GPU.key], a.purchase_date);
}); });
// 변경일시(updated_at) 내림차순 정렬 (최신 변경 항목이 맨 위로) return sortAssets(list);
return list.sort((a: any, b: any) => {
const dateA = a.updated_at || a.created_at || '';
const dateB = b.updated_at || b.created_at || '';
if (dateA < dateB) return 1;
if (dateA > dateB) return -1;
return 0;
});
}, },
searchKeys: ['CURRENT_DEPT', 'CURRENT_USER', 'MODEL_NAME', 'MAC_ADDR', 'MANAGER_MAIN', 'ASSET_TYPE'], searchKeys: ['CURRENT_DEPT', 'CURRENT_USER', 'MODEL_NAME', 'MAC_ADDR', 'MANAGER_MAIN', 'ASSET_TYPE'],
filterOptions: { filterOptions: {
@@ -104,8 +93,7 @@ export function renderPcList(container: HTMLElement) {
width: '8%', width: '8%',
render: a => { render: a => {
const score = a._pc_score !== undefined ? a._pc_score : calculatePcScoreDeductive(a[ASSET_SCHEMA.CPU.key], a[ASSET_SCHEMA.RAM.key], a[ASSET_SCHEMA.GPU.key], a.purchase_date); const score = a._pc_score !== undefined ? a._pc_score : calculatePcScoreDeductive(a[ASSET_SCHEMA.CPU.key], a[ASSET_SCHEMA.RAM.key], a[ASSET_SCHEMA.GPU.key], a.purchase_date);
const isWin11Incompatible = isWindows11Incompatible(a[ASSET_SCHEMA.CPU.key], a[ASSET_SCHEMA.RAM.key]); const grade = getPcGrade(score);
const grade = getPcGrade(score, isWin11Incompatible);
return `<span class="badge ${grade.class}" title="성능 점수: ${score}점">${grade.name}</span>`; return `<span class="badge ${grade.class}" title="성능 점수: ${score}점">${grade.name}</span>`;
} }
} }

View File

@@ -1,10 +0,0 @@
@echo off
chcp 65001 >nul
cd /d "%~dp0"
powershell -ExecutionPolicy Bypass -File "%~dp0start_docker_wsl.ps1"
if errorlevel 1 (
echo.
echo [ERROR] start_docker_wsl.ps1 failed.
pause
exit /b %errorlevel%
)

View File

@@ -1,107 +0,0 @@
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$projectWindowsPath = $PSScriptRoot
$wslProjectPath = (wsl wslpath $projectWindowsPath).Trim()
$envFilePath = Join-Path $PSScriptRoot '.env'
function Get-EnvValue {
param(
[string]$FilePath,
[string]$Key
)
if (-not (Test-Path $FilePath)) {
return $null
}
$line = Get-Content $FilePath | Where-Object { $_ -match "^$Key=" } | Select-Object -First 1
if (-not $line) {
return $null
}
return ($line -split '=', 2)[1].Trim()
}
function Test-TcpPortFast {
param(
[string]$HostName,
[int]$Port,
[int]$TimeoutMs = 3000
)
$client = New-Object System.Net.Sockets.TcpClient
try {
$asyncResult = $client.BeginConnect($HostName, $Port, $null, $null)
if (-not $asyncResult.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) {
$client.Close()
return $false
}
$client.EndConnect($asyncResult)
$client.Close()
return $true
}
catch {
$client.Close()
return $false
}
}
Write-Host "============================================" -ForegroundColor Cyan
Write-Host " HM ITAM WSL Docker Start" -ForegroundColor Cyan
Write-Host "============================================" -ForegroundColor Cyan
Write-Host ""
Write-Host "[INFO] Checking WSL..."
wsl -l -v
if ($LASTEXITCODE -ne 0) {
Write-Host "[ERROR] WSL is not available." -ForegroundColor Red
exit 1
}
Write-Host "[INFO] Checking Docker in WSL..."
wsl sh -lc "docker --version"
if ($LASTEXITCODE -ne 0) {
Write-Host "[ERROR] Docker is not available inside WSL." -ForegroundColor Red
exit 1
}
$dbHost = Get-EnvValue -FilePath $envFilePath -Key 'DB_HOST'
$dbPort = Get-EnvValue -FilePath $envFilePath -Key 'DB_PORT'
if (-not $dbPort) {
$dbPort = '3306'
}
if (-not $dbHost) {
Write-Host "[WARN] .env is missing DB_HOST. Containers will still start, but backend DB calls will fail until DB settings are fixed." -ForegroundColor Yellow
}
if ($dbHost) {
Write-Host "[INFO] Checking external DB reachability..."
$dbReachable = Test-TcpPortFast -HostName $dbHost -Port ([int]$dbPort)
if (-not $dbReachable) {
Write-Host "[WARN] External DB is unreachable: $dbHost`:$dbPort" -ForegroundColor Yellow
Write-Host "[HINT] Containers will still start. Check VPN/private network connection, firewall rules, DB host/port in .env, or whether the DB server is running." -ForegroundColor Yellow
}
}
Write-Host "[INFO] Starting ITAM containers in WSL..."
wsl sh -lc "cd '$wslProjectPath' && docker compose up --build -d --remove-orphans"
if ($LASTEXITCODE -ne 0) {
Write-Host "[WARN] Build-based startup failed. Retrying with cached images/containers..." -ForegroundColor Yellow
wsl sh -lc "cd '$wslProjectPath' && docker compose up -d --remove-orphans"
if ($LASTEXITCODE -ne 0) {
Write-Host "[ERROR] Failed to start containers." -ForegroundColor Red
exit 1
}
}
Write-Host ""
Write-Host "============================================" -ForegroundColor Green
Write-Host " [OK] WSL Docker stack started." -ForegroundColor Green
Write-Host " [INFO] Frontend: http://localhost:8080"
Write-Host " [INFO] Backend : http://localhost:3000/api/assets/master"
Write-Host "============================================" -ForegroundColor Green
Start-Process "http://localhost:8080"

View File

@@ -1,49 +1,6 @@
# HM ITAM Server Start Script # HM ITAM Server Start Script
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 [Console]::OutputEncoding = [System.Text.Encoding]::UTF8
function Get-EnvValue {
param(
[string]$FilePath,
[string]$Key
)
if (-not (Test-Path $FilePath)) {
return $null
}
$line = Get-Content $FilePath | Where-Object { $_ -match "^$Key=" } | Select-Object -First 1
if (-not $line) {
return $null
}
return ($line -split '=', 2)[1].Trim()
}
function Test-TcpPortFast {
param(
[string]$HostName,
[int]$Port,
[int]$TimeoutMs = 3000
)
$client = New-Object System.Net.Sockets.TcpClient
try {
$asyncResult = $client.BeginConnect($HostName, $Port, $null, $null)
if (-not $asyncResult.AsyncWaitHandle.WaitOne($TimeoutMs, $false)) {
$client.Close()
return $false
}
$client.EndConnect($asyncResult)
$client.Close()
return $true
}
catch {
$client.Close()
return $false
}
}
Write-Host "============================================" -ForegroundColor Cyan Write-Host "============================================" -ForegroundColor Cyan
Write-Host " HM ITAM System Start" -ForegroundColor Cyan Write-Host " HM ITAM System Start" -ForegroundColor Cyan
Write-Host "============================================" -ForegroundColor Cyan Write-Host "============================================" -ForegroundColor Cyan
@@ -64,13 +21,6 @@ if (-not (Test-Path "node_modules")) {
Write-Host "[INFO] Checking ports..." Write-Host "[INFO] Checking ports..."
$backendPort = 3000 $backendPort = 3000
$frontendPort = 8080 $frontendPort = 8080
$envFilePath = Join-Path $PSScriptRoot '.env'
$dbHost = Get-EnvValue -FilePath $envFilePath -Key 'DB_HOST'
$dbPort = Get-EnvValue -FilePath $envFilePath -Key 'DB_PORT'
if (-not $dbPort) {
$dbPort = '3306'
}
if (Get-NetTCPConnection -LocalPort $backendPort -ErrorAction SilentlyContinue) { if (Get-NetTCPConnection -LocalPort $backendPort -ErrorAction SilentlyContinue) {
Write-Host "[WARNING] Port $backendPort [Backend] is already in use." -ForegroundColor Yellow Write-Host "[WARNING] Port $backendPort [Backend] is already in use." -ForegroundColor Yellow
@@ -80,21 +30,6 @@ if (Get-NetTCPConnection -LocalPort $frontendPort -ErrorAction SilentlyContinue)
Write-Host "[WARNING] Port $frontendPort [Frontend] is already in use." -ForegroundColor Yellow Write-Host "[WARNING] Port $frontendPort [Frontend] is already in use." -ForegroundColor Yellow
} }
if (-not $dbHost) {
Write-Host "[WARNING] .env is missing DB_HOST. Backend and frontend will still start, but DB calls will fail until DB settings are fixed." -ForegroundColor Yellow
}
else {
Write-Host "[INFO] Checking external DB reachability..."
$dbReachable = Test-TcpPortFast -HostName $dbHost -Port ([int]$dbPort)
if ($dbReachable) {
Write-Host "[INFO] External DB reachable: $dbHost`:$dbPort"
}
else {
Write-Host "[WARNING] External DB is unreachable: $dbHost`:$dbPort" -ForegroundColor Yellow
Write-Host "[WARNING] Backend and frontend will still start, but DB-backed screens and APIs may fail." -ForegroundColor Yellow
}
}
Write-Host "" Write-Host ""
Write-Host "[INFO] Starting Backend [Port: 3000]..." Write-Host "[INFO] Starting Backend [Port: 3000]..."
Start-Process cmd -ArgumentList "/k npm run server" Start-Process cmd -ArgumentList "/k npm run server"

View File

@@ -1,4 +0,0 @@
@echo off
chcp 65001 >nul
cd /d "%~dp0"
powershell -ExecutionPolicy Bypass -File "%~dp0stop_docker_wsl.ps1"

View File

@@ -1,13 +0,0 @@
[Console]::OutputEncoding = [System.Text.Encoding]::UTF8
$projectWindowsPath = $PSScriptRoot
$wslProjectPath = (wsl wslpath $projectWindowsPath).Trim()
Write-Host "[INFO] Stopping ITAM WSL Docker stack..."
wsl sh -lc "cd '$wslProjectPath' && docker compose down --remove-orphans"
if ($LASTEXITCODE -ne 0) {
Write-Host "[ERROR] Failed to stop containers." -ForegroundColor Red
exit 1
}
Write-Host "[OK] WSL Docker stack stopped." -ForegroundColor Green

View File

@@ -1,18 +1,16 @@
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
const proxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3000';
export default defineConfig({ export default defineConfig({
server: { server: {
port: 8080, port: 8080,
host: true, // Listen on all local IPs host: true, // Listen on all local IPs
proxy: { proxy: {
'/api': { '/api': {
target: proxyTarget, target: 'http://localhost:3000',
changeOrigin: true, changeOrigin: true,
}, },
'/uploads': { '/uploads': {
target: proxyTarget, target: 'http://localhost:3000',
changeOrigin: true, changeOrigin: true,
} }
} }