52 Commits

Author SHA1 Message Date
이태훈
87459c8f44 cleanup: 시스템 구동에 불필요한 백업, 로그 및 비필수 문서 파일 정리
All checks were successful
ITAM Code Check / build-and-config-check (push) Successful in 12s
ITAM Docker Build Check / docker-build-check (push) Successful in 29s
2026-06-25 20:19:57 +09:00
이태훈
4d98d9a48e docs: 이슈 보고서 및 thoon 브랜치 작업 가이드라인 추가 2026-06-25 20:15:16 +09:00
이태훈
1da75e4abd feat: 테이블 가로 스크롤 및 컬럼 리사이징 개선, 검색 결과 개수 필터 우측 배치, PC부품 제조사 컬럼 삭제 2026-06-25 20:14:12 +09:00
이태훈
3e26420945 feat: 자산 목록 필터 통일화 (자산위치 제거 및 상태 필터 추가) 2026-06-25 17:51:34 +09:00
이태훈
8e22c1d713 fix: 테이블 리사이저 드래그 시 이웃 컬럼 영향 없이 단방향(드래그 방향)으로만 너비가 바뀌도록 개선
All checks were successful
ITAM Code Check / build-and-config-check (push) Successful in 13s
ITAM Docker Build Check / docker-build-check (push) Successful in 37s
2026-06-25 17:18:26 +09:00
이태훈
8747b3946f feat: 모든 자산 목록 뷰에 마우스 드래그를 이용한 테이블 컬럼 너비 조절(Resizable Columns) 기능 추가 2026-06-25 17:16:44 +09:00
이태훈
ed3d8812c2 config: 운영 DB 접속 환경변수를 내부 컨테이너(itam-mysql) 사양으로 수정
All checks were successful
ITAM Code Check / build-and-config-check (push) Successful in 13s
2026-06-25 14:29:28 +09:00
이태훈
5588fae6f9 config: 로컬/배포 공통 백엔드 PORT 설정을 3000으로 원상 복구
All checks were successful
ITAM Code Check / build-and-config-check (push) Successful in 13s
2026-06-25 14:26:45 +09:00
이태훈
e6afe2b6d3 fix: 배포 워크플로우 내 checkout conflict 방지를 위해 로컬 .env 자동 초기화 추가
All checks were successful
ITAM Code Check / build-and-config-check (push) Successful in 13s
2026-06-25 14:22:31 +09:00
이태훈
9049b60ee5 config: 운영 DB 호스트 주소를 172.16.10.175로 수정
All checks were successful
ITAM Code Check / build-and-config-check (push) Successful in 13s
2026-06-25 14:21:21 +09:00
이태훈
a5c4a15fab fix: .dockerignore에 mysql_data, scratch, *.sql 제외 규칙 추가하여 빌드 에러 수정
All checks were successful
ITAM Code Check / build-and-config-check (push) Successful in 14s
2026-06-25 14:15:37 +09:00
이태훈
1ab59bc9e1 fix: Dockerfile.frontend.prod에 mobile.html 복사 단계 추가하여 빌드 에러 수정
All checks were successful
ITAM Code Check / build-and-config-check (push) Successful in 13s
ITAM Docker Build Check / docker-build-check (push) Successful in 16s
2026-06-25 14:13:55 +09:00
이태훈
7389ed2d82 feat: PC 목록의 현 사용자를 '사용자'로 변경 및 상태가 '재고'일 때 직전 사용자 표시
Some checks failed
ITAM Code Check / build-and-config-check (push) Successful in 13s
ITAM Docker Build Check / docker-build-check (push) Failing after 32s
2026-06-25 14:03:40 +09:00
이태훈
a73dd76e70 feat: 자산 추가/수정 모달에 사용자 자동완성(사번, 직급, 부서 자동 연계) 기능 추가 2026-06-25 13:58:25 +09:00
이태훈
cbfc1bcd1d feat: 자산 정보 수정 시 메모, 용도, 접속정보, 유형 변경 사항도 시스템 이력에 기록되도록 개선 2026-06-25 13:54:49 +09:00
이태훈
6ed939c6bf feat: CPU, GPU, RAM 입력 시 부품 마스터 기준 정합성 검증 추가 및 기존 데이터 정제 2026-06-25 13:41:31 +09:00
이태훈
1ecee53966 Merge branch 'origin/QR_setting' into thoon 2026-06-25 11:39:36 +09:00
이태훈
322a8ae882 fix: 자산번호 생성 시 세부유형 접두사 우선 조회하도록 개선 및 모니터 접두사 MON 추가 2026-06-25 11:24:13 +09:00
이태훈
8176180e52 fix: 프론트엔드 API 호출 시 하드웨어 모달 및 맵 에디터에서 포트 3000 하드코딩 제거하고 상대 경로 프록시 사용하도록 수정 2026-06-25 10:48:52 +09:00
이태훈
2137ee364c fix: 자산 추가/수정 모달에서 서비스 구분 필드 노출 조건 수정 (구분이 서버 또는 유형이 서버PC인 경우만) 2026-06-25 10:30:26 +09:00
이태훈
afd89322bb fix: 카테고리별 자산 목록 화면의 데이터 소스 참조 및 백엔드 맵핑 오류 수정 (PC부품, 공간정보장비, 선물, 사무가구) 2026-06-25 10:26:54 +09:00
이태훈
1457bf277f Merge branch 'main' into thoon 2026-06-25 10:23:02 +09:00
이태훈
0bfff08af6 fix(audit): 실사 승인 대기 목록 필터링 오류 수정 및 승인완료 배지 표시 추가
- GET /api/audit/pending API에서 PENDING 상태인 내역만 반환하도록 SQL 쿼리 수정
- 실사 승인 및 맵 저장 시 asset_location.location_detail에 표준 상세 위치가 저장되도록 개선
- 실사 승인 완료된 자산에 대해 상세 모달, 위치 보기, 목록 보기에 '승인완료' 배지 노출 처리
- 목록 보기에서 기존 위치 표시 형식 및 툴팁을 훼손하지 않도록 배지를 분리하여 렌더링
2026-06-24 17:56:41 +09:00
SDI
ae1fd4b121 feat: 도커 DB생성 compose파일 수정(현재 main 브랜치에서 pull해서 적용)
All checks were successful
ITAM Code Check / build-and-config-check (push) Successful in 12s
ITAM Docker Build Check / docker-build-check (push) Successful in 16s
2026-06-24 16:19:33 +09:00
이태훈
1eca0ede91 fix: allowedHosts 설정 추가 및 모바일 QR 코드 스캔 시 줄바꿈/공백 정규식 정제 로직 적용 2026-06-23 17:15:48 +09:00
이태훈
f36e8e93e2 feat: QR 자산 스캔 점검, 모바일 웹뷰 및 관리자 승인 시스템 구현 (DB 기반 맵 좌표 저장 단일화 포함) 2026-06-23 16:39:14 +09:00
이태훈
577f138533 fix: 위치보기 수정 (도면 오버플로우 제한 및 API 호출 경로 정상화)
All checks were successful
ITAM Code Check / build-and-config-check (push) Successful in 18s
2026-06-22 13:58:01 +09:00
이태훈
aacd2fe7db fix: copy map_editor.html to docker builder stage
All checks were successful
ITAM Code Check / build-and-config-check (push) Successful in 17s
ITAM Docker Build Check / docker-build-check (push) Successful in 21s
2026-06-22 11:46:26 +09:00
이태훈
90403a1acd fix: comment out obsolete COPY img in Dockerfile.frontend.prod 2026-06-22 11:42:42 +09:00
이태훈
6a76f6968b fix: convert scripts/backup.sh line endings from CRLF to LF 2026-06-22 11:36:50 +09:00
이태훈
621b05a890 chore: clean up build artifacts, temporary excel locks, duplicate plans, and commit current project state
Some checks failed
ITAM Code Check / build-and-config-check (push) Successful in 18s
ITAM Docker Build Check / docker-build-check (push) Failing after 21s
2026-06-22 11:26:26 +09:00
7b631ab858 fix: 원격접속 자산 데이터 매핑 보완
Some checks failed
ITAM Code Check / build-and-config-check (push) Successful in 20s
ITAM Docker Build Check / docker-build-check (push) Failing after 22s
2026-06-19 18:03:58 +09:00
9735344f37 Merge remote-tracking branch 'origin/main'
Some checks failed
ITAM Code Check / build-and-config-check (push) Successful in 21s
ITAM Docker Build Check / docker-build-check (push) Failing after 23s
# Conflicts:
#	src/views/List/ListFactory.ts
2026-06-19 17:58:22 +09:00
SDI
67e3be028b fix: enable location map box clicks
All checks were successful
ITAM Docker Build Check / docker-build-check (push) Successful in 25s
ITAM Code Check / build-and-config-check (push) Successful in 20s
2026-06-19 17:31:37 +09:00
SDI
58f93c959d fix: use proxied api routes in frontend
All checks were successful
ITAM Code Check / build-and-config-check (push) Successful in 20s
ITAM Docker Build Check / docker-build-check (push) Successful in 26s
2026-06-19 17:20:06 +09:00
4231acc691 Merge branch 'db_setting'
Some checks failed
ITAM Code Check / build-and-config-check (push) Successful in 21s
ITAM Docker Build Check / docker-build-check (push) Failing after 38s
2026-06-19 16:26:16 +09:00
SDI
662f720c6a fix: use updated backup tooling before deploy cleanup
All checks were successful
ITAM Code Check / build-and-config-check (push) Successful in 20s
ITAM Docker Build Check / docker-build-check (push) Successful in 19s
2026-06-19 15:58:39 +09:00
SDI
5678e28c66 fix: load local env file correctly in backup script
All checks were successful
ITAM Code Check / build-and-config-check (push) Successful in 20s
2026-06-19 15:53:04 +09:00
SDI
15c5cbaca2 Merge remote-tracking branch 'origin/main'
All checks were successful
ITAM Code Check / build-and-config-check (push) Successful in 21s
ITAM Docker Build Check / docker-build-check (push) Successful in 26s
2026-06-19 15:47:18 +09:00
SDI
84d35c1409 fix: smoke check관련 수정 2026-06-19 15:37:50 +09:00
SDI
07eb48f27c chore: force update smoke check to direct backend health check 2026-06-19 15:28:50 +09:00
SDI
fb45c38107 fix: update smoke checks to use port 9090 and direct backend health check2 2026-06-19 15:25:52 +09:00
SDI
6c21e4816e fix: update smoke checks to use port 9090 and direct backend health check 2026-06-19 15:21:11 +09:00
SDI
e208e52ed9 feat: implement container-based backup using docker exec 2026-06-19 14:34:09 +09:00
SDI
5dbf69e963 fix: 백업 디렉토리 경로 설정 수정 2026-06-19 14:02:36 +09:00
SDI
d771b28d88 fix: change port from 80 to 9090 2026-06-19 13:51:40 +09:00
6848baae5f Merge pull request 'feat: 자산관리 시스템 도커 운영 배포 환경 및 CI/CD 구축' (#21) from feature/docker-deploy into main
All checks were successful
ITAM Docker Build Check / docker-build-check (push) Successful in 18s
ITAM Code Check / build-and-config-check (push) Successful in 20s
Reviewed-on: #21
2026-06-19 10:50:17 +09:00
SDI
a0570e88d4 fix: Dockerfile 불필요한 이미지 복사 명령어 제거
All checks were successful
ITAM Code Check / build-and-config-check (pull_request) Successful in 20s
ITAM Docker Build Check / docker-build-check (pull_request) Successful in 21s
2026-06-19 10:48:01 +09:00
SDI
502e5059b7 Merge remote-tracking branch 'origin/main' into feature/docker-deploy
Some checks failed
ITAM Code Check / build-and-config-check (pull_request) Successful in 22s
ITAM Docker Build Check / docker-build-check (pull_request) Failing after 22s
2026-06-19 10:27:37 +09:00
SDI
d54997cd55 fix: prepare ci env for compose validation 2026-06-18 13:52:05 +09:00
SDI
fa8dec1780 feat:CI/CD Gitea 워크플로우 등 누락 파일 반영 2026-06-18 13:39:35 +09:00
9d19d8283e 자산관리 시스템 도커라이징 2026-06-17 11:31:10 +09:00
156 changed files with 17463 additions and 35273 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
node_modules
dist
build
.git
.gitignore
.env
npm-debug.log
uploads
*.xlsx
*.log
mysql_data
scratch
*.sql

4
.env
View File

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

17
.env.example Normal file
View File

@@ -0,0 +1,17 @@
# Database Configuration
DB_HOST=172.16.8.151
DB_PORT=3306
DB_USER=itam_admin
DB_PASS=itam1234
DB_NAME=itam
# Application Configuration
NODE_ENV=development
PORT=3000
# Logging (optional)
LOG_LEVEL=info
# Security (for production)
# API_KEY=your_api_key_here
# JWT_SECRET=your_jwt_secret_here

7
.gitea/coverage.json Normal file
View File

@@ -0,0 +1,7 @@
{
"Path": "./backend/coverage.out",
"Thresholds": {
"baron-sso-backend/internal/handler": 10,
"baron-sso-backend/internal/service": 10
}
}

View File

@@ -0,0 +1,47 @@
name: ITAM Code Check
on:
push:
branches:
- Dockerizing
- main
pull_request:
workflow_dispatch:
jobs:
build-and-config-check:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Prepare CI env file
run: |
cat <<'EOF' > .env
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=itam_ci
DB_PASS=itam_ci_password
DB_NAME=itam
NODE_ENV=production
PORT=3000
LOG_LEVEL=info
EOF
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Frontend TypeScript and Vite build
run: npm run build
- name: Validate test compose
run: docker compose -f docker-compose.test.yaml config
- name: Validate prod compose
run: docker compose -f docker-compose.prod.yaml config

View File

@@ -0,0 +1,69 @@
name: ITAM Docker Build Check
on:
push:
branches:
- Dockerizing
- main
paths:
- "Dockerfile.frontend.prod"
- "Dockerfile.backend.prod"
- "docker-compose.prod.yaml"
- "docker-compose.test.yaml"
- "docker/**"
- "src/**"
- "server.js"
- "package.json"
- "package-lock.json"
- "vite.config.ts"
- "index.html"
- "img/**"
- "public/**"
pull_request:
paths:
- "Dockerfile.frontend.prod"
- "Dockerfile.backend.prod"
- "docker-compose.prod.yaml"
- "docker-compose.test.yaml"
- "docker/**"
- "src/**"
- "server.js"
- "package.json"
- "package-lock.json"
- "vite.config.ts"
- "index.html"
- "img/**"
- "public/**"
workflow_dispatch:
jobs:
docker-build-check:
runs-on: ubuntu-latest
env:
DOCKER_BUILDKIT: "1"
COMPOSE_DOCKER_CLI_BUILD: "1"
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Prepare CI env file
run: |
cat <<'EOF' > .env
DB_HOST=127.0.0.1
DB_PORT=3306
DB_USER=itam_ci
DB_PASS=itam_ci_password
DB_NAME=itam
NODE_ENV=production
PORT=3000
LOG_LEVEL=info
EOF
- name: Build backend production image
run: docker build -f Dockerfile.backend.prod -t itam-backend:ci .
- name: Build frontend production image
run: docker build -f Dockerfile.frontend.prod -t itam-frontend:ci .
- name: Validate production compose with CI env
run: docker compose -f docker-compose.prod.yaml config

View File

@@ -0,0 +1,143 @@
name: ITAM Production Deploy
on:
workflow_dispatch:
inputs:
target_branch:
description: "Branch to deploy"
required: true
default: "main"
type: string
jobs:
deploy-production:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Setup SSH agent
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
- name: Validate required production variables
env:
PROD_HOST: ${{ vars.PROD_HOST }}
PROD_USER: ${{ vars.PROD_USER }}
PROD_DEPLOY_PATH: ${{ vars.PROD_DEPLOY_PATH }}
PROD_GIT_URL: ${{ vars.PROD_GIT_URL }}
DB_HOST: ${{ vars.PROD_DB_HOST }}
DB_PORT: ${{ vars.PROD_DB_PORT }}
DB_USER: ${{ vars.PROD_DB_USER }}
DB_PASS: ${{ secrets.PROD_DB_PASS }}
DB_NAME: ${{ vars.PROD_DB_NAME }}
run: |
set -euo pipefail
required_keys="PROD_HOST PROD_USER PROD_DEPLOY_PATH PROD_GIT_URL DB_HOST DB_PORT DB_USER DB_PASS DB_NAME"
for key in ${required_keys}; do
if [ -z "${!key:-}" ]; then
echo "::error::Missing required variable or secret: ${key}"
exit 1
fi
done
- name: Create production env file
env:
DB_HOST: ${{ vars.PROD_DB_HOST }}
DB_PORT: ${{ vars.PROD_DB_PORT }}
DB_USER: ${{ vars.PROD_DB_USER }}
DB_PASS: ${{ secrets.PROD_DB_PASS }}
DB_NAME: ${{ vars.PROD_DB_NAME }}
LOG_LEVEL: ${{ vars.PROD_LOG_LEVEL }}
run: |
set -euo pipefail
EFFECTIVE_LOG_LEVEL="${LOG_LEVEL:-info}"
cat > .env.deploy <<EOF
DB_HOST=${DB_HOST}
DB_PORT=${DB_PORT}
DB_USER=${DB_USER}
DB_PASS=${DB_PASS}
DB_NAME=${DB_NAME}
NODE_ENV=production
PORT=3000
LOG_LEVEL=${EFFECTIVE_LOG_LEVEL}
EOF
- name: Deploy to production host
env:
PROD_HOST: ${{ vars.PROD_HOST }}
PROD_USER: ${{ vars.PROD_USER }}
PROD_DEPLOY_PATH: ${{ vars.PROD_DEPLOY_PATH }}
PROD_BACKUP_ROOT: ${{ vars.PROD_BACKUP_ROOT }}
PROD_GIT_URL: ${{ vars.PROD_GIT_URL }}
DB_HOST: ${{ vars.PROD_DB_HOST }}
DB_PORT: ${{ vars.PROD_DB_PORT }}
DB_USER: ${{ vars.PROD_DB_USER }}
DB_PASS: ${{ secrets.PROD_DB_PASS }}
DB_NAME: ${{ vars.PROD_DB_NAME }}
TARGET_BRANCH: ${{ github.event.inputs.target_branch }}
run: |
set -euo pipefail
ssh-keyscan -H "${PROD_HOST}" >> ~/.ssh/known_hosts
ssh "${PROD_USER}@${PROD_HOST}" "mkdir -p '${PROD_DEPLOY_PATH}'"
ssh "${PROD_USER}@${PROD_HOST}" "if [ ! -d '${PROD_DEPLOY_PATH}/.git' ]; then git clone '${PROD_GIT_URL}' '${PROD_DEPLOY_PATH}'; else cd '${PROD_DEPLOY_PATH}' && git remote set-url origin '${PROD_GIT_URL}'; fi"
ssh "${PROD_USER}@${PROD_HOST}" "cd '${PROD_DEPLOY_PATH}' && git checkout -- .env || true && git fetch origin '${TARGET_BRANCH}' && git checkout -B '${TARGET_BRANCH}' FETCH_HEAD && git reset --hard FETCH_HEAD"
EFFECTIVE_BACKUP_ROOT="${PROD_BACKUP_ROOT:-/home/user/dachs_backups}"
ssh "${PROD_USER}@${PROD_HOST}" "export DEPLOY_PATH='${PROD_DEPLOY_PATH}' BACKUP_ROOT='${EFFECTIVE_BACKUP_ROOT}'; sh -eu -s" <<'REMOTE_BACKUP'
case "$BACKUP_ROOT" in
"$DEPLOY_PATH"|"$DEPLOY_PATH"/*)
echo "Backup path must be outside deploy path: $BACKUP_ROOT"
exit 1
;;
esac
if [ -d "$DEPLOY_PATH/.git" ]; then
mkdir -p "$BACKUP_ROOT"
echo "Starting pre-deploy backup..."
cd "$DEPLOY_PATH"
if [ -f Makefile ] && [ -f scripts/backup.sh ] && [ -f .env ]; then
make predeploy-backup ENV_FILE=.env BACKUP_ROOT="$BACKUP_ROOT"
else
echo "Skipping pre-deploy backup because required backup files are missing in current deployment."
fi
else
echo "Skipping pre-deploy backup because no existing deployment was found."
fi
REMOTE_BACKUP
ssh "${PROD_USER}@${PROD_HOST}" "cd '${PROD_DEPLOY_PATH}' && git clean -fd"
ssh "${PROD_USER}@${PROD_HOST}" "cd '${PROD_DEPLOY_PATH}' && mkdir -p uploads logs/nginx"
scp .env.deploy "${PROD_USER}@${PROD_HOST}:${PROD_DEPLOY_PATH}/.env"
ssh "${PROD_USER}@${PROD_HOST}" "cd '${PROD_DEPLOY_PATH}' && chmod 600 .env && docker compose -f docker-compose.prod.yaml config && docker compose -f docker-compose.prod.yaml up -d --build"
- name: Post-deploy status check
env:
PROD_HOST: ${{ vars.PROD_HOST }}
PROD_USER: ${{ vars.PROD_USER }}
PROD_DEPLOY_PATH: ${{ vars.PROD_DEPLOY_PATH }}
run: |
set -euo pipefail
ssh "${PROD_USER}@${PROD_HOST}" "cd '${PROD_DEPLOY_PATH}' && docker compose -f docker-compose.prod.yaml ps"
- name: Post-deploy smoke checks
env:
PROD_HOST: ${{ vars.PROD_HOST }}
PROD_USER: ${{ vars.PROD_USER }}
PROD_DEPLOY_PATH: ${{ vars.PROD_DEPLOY_PATH }}
run: |
set -euo pipefail
ssh "${PROD_USER}@${PROD_HOST}" "curl -fsS http://localhost:9090/health"
ssh "${PROD_USER}@${PROD_HOST}" "cd '${PROD_DEPLOY_PATH}' && docker compose -f docker-compose.prod.yaml exec -T backend curl -fsS http://localhost:3000/health"
- name: Cleanup generated env file
if: ${{ always() }}
run: rm -f .env.deploy

2
.gitignore vendored
View File

@@ -5,3 +5,5 @@ dist/
*.log
.DS_Store
Thumbs.db
backups/
mysql_data/

12
Dockerfile.backend Normal file
View File

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

48
Dockerfile.backend.prod Normal file
View File

@@ -0,0 +1,48 @@
FROM node:20-alpine
LABEL maintainer="ITAM Team <devops@itam.local>"
# Set production environment
ENV NODE_ENV=production
WORKDIR /app
# Install curl for health checks and dumb-init for proper signal handling
RUN apk add --no-cache curl dumb-init mysql-client
# Copy package files
COPY package*.json ./
# Install production dependencies only
RUN npm ci --only=production
# Copy application code
COPY server.js ./
COPY src ./src
# Create non-root user 'appuser' with UID 1001 (1000 already in use by node image)
RUN addgroup -g 1001 appuser && \
adduser -D -u 1001 -G appuser appuser
# Set ownership of application files to appuser
RUN chown -R appuser:appuser /app
# Create logs directory
RUN mkdir -p /app/logs && \
chown -R appuser:appuser /app/logs
# Switch to non-root user
USER appuser
# Expose port
EXPOSE 3000
# Health check - backend should implement /health endpoint
HEALTHCHECK --interval=30s --timeout=10s --start-period=40s --retries=3 \
CMD curl -f http://localhost:3000/health || exit 1
# Use dumb-init from PATH to avoid distro-specific absolute path issues
ENTRYPOINT ["dumb-init", "--"]
# Run application
CMD ["npm", "run", "server"]

12
Dockerfile.frontend Normal file
View File

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

62
Dockerfile.frontend.prod Normal file
View File

@@ -0,0 +1,62 @@
# Stage 1: Build
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
COPY tsconfig*.json ./
COPY vite.config.ts ./
# Install all dependencies (including devDependencies for build)
RUN npm ci
# Copy source code
COPY src ./src
COPY public ./public
COPY index.html map_editor.html mobile.html ./
# Build application
RUN npm run build
# Verify build output
RUN ls -la dist/ && echo "Build completed successfully"
# Stage 2: Runtime
FROM nginx:stable-alpine
LABEL maintainer="ITAM Team <devops@itam.local>"
# Install curl for health checks
RUN apk add --no-cache curl
WORKDIR /usr/share/nginx/html
# Copy built assets from builder
COPY --from=builder /app/dist .
# Copy static image assets referenced by literal /img/... paths (Obsolete: img folder is now public/img and copied via dist)
# COPY img ./img
# Copy root-level logo asset referenced directly by index.html
# COPY ["image 92.png", "./image 92.png"]
# Copy Nginx static file serving configuration (not reverse proxy)
COPY docker/frontend/default.conf /etc/nginx/conf.d/default.conf
# Create nginx runtime user and directories
RUN mkdir -p /var/log/nginx && \
chown -R nginx:nginx /usr/share/nginx/html && \
chown -R nginx:nginx /var/log/nginx && \
chown -R nginx:nginx /var/cache/nginx && \
chown -R nginx:nginx /etc/nginx/conf.d
# Expose port
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=20s --retries=3 \
CMD curl -f http://localhost:80/ || exit 1
# Run nginx
CMD ["nginx", "-g", "daemon off;"]

33
Makefile Normal file
View File

@@ -0,0 +1,33 @@
SHELL := /bin/sh
ENV_FILE ?= .env
BACKUP_ROOT ?= backups
RETENTION_DAYS ?= 14
BACKUP_SCRIPT := scripts/backup.sh
.PHONY: help db-dump files-backup full-backup predeploy-backup cleanup-backups
help:
@echo "Usage: make <target> [ENV_FILE=.env BACKUP_ROOT=backups RETENTION_DAYS=14]"
@echo ""
@echo "Targets:"
@echo " db-dump Create a gzip-compressed MySQL dump from .env settings"
@echo " files-backup Archive runtime files such as uploads/, map_config.json, and .env"
@echo " full-backup Run both db-dump and files-backup"
@echo " predeploy-backup Alias for the backup step executed before production deploy"
@echo " cleanup-backups Delete backup files older than RETENTION_DAYS"
db-dump:
@ENV_FILE="$(ENV_FILE)" BACKUP_ROOT="$(BACKUP_ROOT)" sh "$(BACKUP_SCRIPT)" db
files-backup:
@ENV_FILE="$(ENV_FILE)" BACKUP_ROOT="$(BACKUP_ROOT)" sh "$(BACKUP_SCRIPT)" files
full-backup:
@ENV_FILE="$(ENV_FILE)" BACKUP_ROOT="$(BACKUP_ROOT)" sh "$(BACKUP_SCRIPT)" full
predeploy-backup:
@ENV_FILE="$(ENV_FILE)" BACKUP_ROOT="$(BACKUP_ROOT)" sh "$(BACKUP_SCRIPT)" full
cleanup-backups:
@BACKUP_ROOT="$(BACKUP_ROOT)" RETENTION_DAYS="$(RETENTION_DAYS)" sh "$(BACKUP_SCRIPT)" cleanup

File diff suppressed because it is too large Load Diff

View File

@@ -1,211 +0,0 @@
('D:\\이태훈\\22전산자산조사\\ITAM\\dist\\pc_agent.exe',
True,
False,
False,
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\PyInstaller\\bootloader\\images\\icon-console.ico',
None,
False,
False,
b'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n<assembly xmlns='
b'"urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">\n <trustInfo x'
b'mlns="urn:schemas-microsoft-com:asm.v3">\n <security>\n <requested'
b'Privileges>\n <requestedExecutionLevel level="asInvoker" uiAccess='
b'"false"/>\n </requestedPrivileges>\n </security>\n </trustInfo>\n '
b'<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">\n <'
b'application>\n <supportedOS Id="{e2011457-1546-43c5-a5fe-008deee3d3f'
b'0}"/>\n <supportedOS Id="{35138b9a-5d96-4fbd-8e2d-a2440225f93a}"/>\n '
b' <supportedOS Id="{4a2f28e3-53b9-4441-ba9c-d69d4a4a6e38}"/>\n <s'
b'upportedOS Id="{1f676c76-80e1-4239-95bb-83d0f6d0da78}"/>\n <supporte'
b'dOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}"/>\n </application>\n <'
b'/compatibility>\n <application xmlns="urn:schemas-microsoft-com:asm.v3">'
b'\n <windowsSettings>\n <longPathAware xmlns="http://schemas.micros'
b'oft.com/SMI/2016/WindowsSettings">true</longPathAware>\n </windowsSett'
b'ings>\n </application>\n <dependency>\n <dependentAssembly>\n <ass'
b'emblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version='
b'"6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" langua'
b'ge="*"/>\n </dependentAssembly>\n </dependency>\n</assembly>',
True,
False,
None,
None,
None,
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\pc_agent.pkg',
[('pyi-contents-directory _internal', '', 'OPTION'),
('PYZ-00.pyz', 'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\PYZ-00.pyz', 'PYZ'),
('struct',
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\localpycs\\struct.pyc',
'PYMODULE'),
('pyimod01_archive',
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\localpycs\\pyimod01_archive.pyc',
'PYMODULE'),
('pyimod02_importers',
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\localpycs\\pyimod02_importers.pyc',
'PYMODULE'),
('pyimod03_ctypes',
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\localpycs\\pyimod03_ctypes.pyc',
'PYMODULE'),
('pyimod04_pywin32',
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\localpycs\\pyimod04_pywin32.pyc',
'PYMODULE'),
('pyiboot01_bootstrap',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\PyInstaller\\loader\\pyiboot01_bootstrap.py',
'PYSOURCE'),
('pyi_rth_inspect',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_inspect.py',
'PYSOURCE'),
('pyi_rth_pkgutil',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_pkgutil.py',
'PYSOURCE'),
('pyi_rth_multiprocessing',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_multiprocessing.py',
'PYSOURCE'),
('pyi_rth_cryptography_openssl',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\rthooks\\pyi_rth_cryptography_openssl.py',
'PYSOURCE'),
('pyi_rth_pywintypes',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\rthooks\\pyi_rth_pywintypes.py',
'PYSOURCE'),
('pyi_rth_pythoncom',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\rthooks\\pyi_rth_pythoncom.py',
'PYSOURCE'),
('pc_agent', 'D:\\이태훈\\22전산자산조사\\ITAM\\pc_agent.py', 'PYSOURCE'),
('python312.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\python312.dll',
'BINARY'),
('pywin32_system32\\pywintypes312.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\pywin32_system32\\pywintypes312.dll',
'BINARY'),
('pywin32_system32\\pythoncom312.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\pywin32_system32\\pythoncom312.dll',
'BINARY'),
('select.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\select.pyd',
'EXTENSION'),
('_multiprocessing.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_multiprocessing.pyd',
'EXTENSION'),
('pyexpat.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\pyexpat.pyd',
'EXTENSION'),
('_ssl.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_ssl.pyd',
'EXTENSION'),
('_hashlib.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_hashlib.pyd',
'EXTENSION'),
('unicodedata.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\unicodedata.pyd',
'EXTENSION'),
('_decimal.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_decimal.pyd',
'EXTENSION'),
('_lzma.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_lzma.pyd',
'EXTENSION'),
('_bz2.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_bz2.pyd',
'EXTENSION'),
('_ctypes.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_ctypes.pyd',
'EXTENSION'),
('_queue.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_queue.pyd',
'EXTENSION'),
('_wmi.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_wmi.pyd',
'EXTENSION'),
('_socket.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_socket.pyd',
'EXTENSION'),
('_overlapped.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_overlapped.pyd',
'EXTENSION'),
('_asyncio.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_asyncio.pyd',
'EXTENSION'),
('_cffi_backend.cp312-win_amd64.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\_cffi_backend.cp312-win_amd64.pyd',
'EXTENSION'),
('cryptography\\hazmat\\bindings\\_rust.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography\\hazmat\\bindings\\_rust.pyd',
'EXTENSION'),
('charset_normalizer\\md__mypyc.cp312-win_amd64.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\charset_normalizer\\md__mypyc.cp312-win_amd64.pyd',
'EXTENSION'),
('charset_normalizer\\md.cp312-win_amd64.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\charset_normalizer\\md.cp312-win_amd64.pyd',
'EXTENSION'),
('win32\\_win32sysloader.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\win32\\_win32sysloader.pyd',
'EXTENSION'),
('win32\\win32api.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\win32\\win32api.pyd',
'EXTENSION'),
('Pythonwin\\win32ui.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\Pythonwin\\win32ui.pyd',
'EXTENSION'),
('win32\\win32event.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\win32\\win32event.pyd',
'EXTENSION'),
('win32\\win32trace.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\win32\\win32trace.pyd',
'EXTENSION'),
('VCRUNTIME140.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\VCRUNTIME140.dll',
'BINARY'),
('VCRUNTIME140_1.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\VCRUNTIME140_1.dll',
'BINARY'),
('libssl-3.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\libssl-3.dll',
'BINARY'),
('libcrypto-3.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\libcrypto-3.dll',
'BINARY'),
('libffi-8.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\libffi-8.dll',
'BINARY'),
('python3.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\python3.dll',
'BINARY'),
('Pythonwin\\mfc140u.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\Pythonwin\\mfc140u.dll',
'BINARY'),
('certifi\\cacert.pem',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\certifi\\cacert.pem',
'DATA'),
('certifi\\py.typed',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\certifi\\py.typed',
'DATA'),
('cryptography-45.0.2.dist-info\\licenses\\LICENSE.BSD',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\licenses\\LICENSE.BSD',
'DATA'),
('cryptography-45.0.2.dist-info\\licenses\\LICENSE',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\licenses\\LICENSE',
'DATA'),
('cryptography-45.0.2.dist-info\\RECORD',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\RECORD',
'DATA'),
('cryptography-45.0.2.dist-info\\METADATA',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\METADATA',
'DATA'),
('cryptography-45.0.2.dist-info\\WHEEL',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\WHEEL',
'DATA'),
('cryptography-45.0.2.dist-info\\INSTALLER',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\INSTALLER',
'DATA'),
('cryptography-45.0.2.dist-info\\licenses\\LICENSE.APACHE',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\licenses\\LICENSE.APACHE',
'DATA'),
('base_library.zip',
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\base_library.zip',
'DATA')],
[],
False,
False,
1779102721,
[('run.exe',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\PyInstaller\\bootloader\\Windows-64bit-intel\\run.exe',
'EXECUTABLE')],
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\python312.dll')

View File

@@ -1,189 +0,0 @@
('D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\pc_agent.pkg',
{'BINARY': True,
'DATA': True,
'EXECUTABLE': True,
'EXTENSION': True,
'PYMODULE': True,
'PYSOURCE': True,
'PYZ': False,
'SPLASH': True,
'SYMLINK': False},
[('pyi-contents-directory _internal', '', 'OPTION'),
('PYZ-00.pyz', 'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\PYZ-00.pyz', 'PYZ'),
('struct',
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\localpycs\\struct.pyc',
'PYMODULE'),
('pyimod01_archive',
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\localpycs\\pyimod01_archive.pyc',
'PYMODULE'),
('pyimod02_importers',
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\localpycs\\pyimod02_importers.pyc',
'PYMODULE'),
('pyimod03_ctypes',
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\localpycs\\pyimod03_ctypes.pyc',
'PYMODULE'),
('pyimod04_pywin32',
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\localpycs\\pyimod04_pywin32.pyc',
'PYMODULE'),
('pyiboot01_bootstrap',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\PyInstaller\\loader\\pyiboot01_bootstrap.py',
'PYSOURCE'),
('pyi_rth_inspect',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_inspect.py',
'PYSOURCE'),
('pyi_rth_pkgutil',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_pkgutil.py',
'PYSOURCE'),
('pyi_rth_multiprocessing',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\PyInstaller\\hooks\\rthooks\\pyi_rth_multiprocessing.py',
'PYSOURCE'),
('pyi_rth_cryptography_openssl',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\rthooks\\pyi_rth_cryptography_openssl.py',
'PYSOURCE'),
('pyi_rth_pywintypes',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\rthooks\\pyi_rth_pywintypes.py',
'PYSOURCE'),
('pyi_rth_pythoncom',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\_pyinstaller_hooks_contrib\\rthooks\\pyi_rth_pythoncom.py',
'PYSOURCE'),
('pc_agent', 'D:\\이태훈\\22전산자산조사\\ITAM\\pc_agent.py', 'PYSOURCE'),
('python312.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\python312.dll',
'BINARY'),
('pywin32_system32\\pywintypes312.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\pywin32_system32\\pywintypes312.dll',
'BINARY'),
('pywin32_system32\\pythoncom312.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\pywin32_system32\\pythoncom312.dll',
'BINARY'),
('select.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\select.pyd',
'EXTENSION'),
('_multiprocessing.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_multiprocessing.pyd',
'EXTENSION'),
('pyexpat.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\pyexpat.pyd',
'EXTENSION'),
('_ssl.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_ssl.pyd',
'EXTENSION'),
('_hashlib.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_hashlib.pyd',
'EXTENSION'),
('unicodedata.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\unicodedata.pyd',
'EXTENSION'),
('_decimal.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_decimal.pyd',
'EXTENSION'),
('_lzma.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_lzma.pyd',
'EXTENSION'),
('_bz2.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_bz2.pyd',
'EXTENSION'),
('_ctypes.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_ctypes.pyd',
'EXTENSION'),
('_queue.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_queue.pyd',
'EXTENSION'),
('_wmi.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_wmi.pyd',
'EXTENSION'),
('_socket.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_socket.pyd',
'EXTENSION'),
('_overlapped.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_overlapped.pyd',
'EXTENSION'),
('_asyncio.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\_asyncio.pyd',
'EXTENSION'),
('_cffi_backend.cp312-win_amd64.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\_cffi_backend.cp312-win_amd64.pyd',
'EXTENSION'),
('cryptography\\hazmat\\bindings\\_rust.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography\\hazmat\\bindings\\_rust.pyd',
'EXTENSION'),
('charset_normalizer\\md__mypyc.cp312-win_amd64.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\charset_normalizer\\md__mypyc.cp312-win_amd64.pyd',
'EXTENSION'),
('charset_normalizer\\md.cp312-win_amd64.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\charset_normalizer\\md.cp312-win_amd64.pyd',
'EXTENSION'),
('win32\\_win32sysloader.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\win32\\_win32sysloader.pyd',
'EXTENSION'),
('win32\\win32api.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\win32\\win32api.pyd',
'EXTENSION'),
('Pythonwin\\win32ui.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\Pythonwin\\win32ui.pyd',
'EXTENSION'),
('win32\\win32event.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\win32\\win32event.pyd',
'EXTENSION'),
('win32\\win32trace.pyd',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\win32\\win32trace.pyd',
'EXTENSION'),
('VCRUNTIME140.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\VCRUNTIME140.dll',
'BINARY'),
('VCRUNTIME140_1.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\VCRUNTIME140_1.dll',
'BINARY'),
('libssl-3.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\libssl-3.dll',
'BINARY'),
('libcrypto-3.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\libcrypto-3.dll',
'BINARY'),
('libffi-8.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\DLLs\\libffi-8.dll',
'BINARY'),
('python3.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\python3.dll',
'BINARY'),
('Pythonwin\\mfc140u.dll',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\Pythonwin\\mfc140u.dll',
'BINARY'),
('certifi\\cacert.pem',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\certifi\\cacert.pem',
'DATA'),
('certifi\\py.typed',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\certifi\\py.typed',
'DATA'),
('cryptography-45.0.2.dist-info\\licenses\\LICENSE.BSD',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\licenses\\LICENSE.BSD',
'DATA'),
('cryptography-45.0.2.dist-info\\licenses\\LICENSE',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\licenses\\LICENSE',
'DATA'),
('cryptography-45.0.2.dist-info\\RECORD',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\RECORD',
'DATA'),
('cryptography-45.0.2.dist-info\\METADATA',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\METADATA',
'DATA'),
('cryptography-45.0.2.dist-info\\WHEEL',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\WHEEL',
'DATA'),
('cryptography-45.0.2.dist-info\\INSTALLER',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\INSTALLER',
'DATA'),
('cryptography-45.0.2.dist-info\\licenses\\LICENSE.APACHE',
'C:\\Users\\User\\AppData\\Local\\Programs\\Python\\Python312\\Lib\\site-packages\\cryptography-45.0.2.dist-info\\licenses\\LICENSE.APACHE',
'DATA'),
('base_library.zip',
'D:\\이태훈\\22전산자산조사\\ITAM\\build\\pc_agent\\base_library.zip',
'DATA')],
'python312.dll',
False,
False,
False,
[],
None,
None,
None)

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1,58 +0,0 @@
This file lists modules PyInstaller was not able to find. This does not
necessarily mean these modules are required for running your program. Both
Python's standard library and 3rd-party Python packages often conditionally
import optional modules, some of which may be available only on certain
platforms.
Types of import:
* top-level: imported at the top-level - look at these first
* conditional: imported within an if-statement
* delayed: imported within a function
* optional: imported within a try-except-statement
IMPORTANT: Do NOT post this list to the issue-tracker. Use it as a basis for
tracking down the missing module yourself. Thanks!
missing module named pwd - imported by posixpath (delayed, conditional, optional), shutil (delayed, optional), tarfile (optional), pathlib (delayed, optional), subprocess (delayed, conditional, optional), netrc (delayed, conditional), getpass (delayed)
missing module named grp - imported by shutil (delayed, optional), tarfile (optional), pathlib (delayed, optional), subprocess (delayed, conditional, optional)
missing module named _posixsubprocess - imported by subprocess (conditional), multiprocessing.util (delayed)
missing module named fcntl - imported by subprocess (optional)
missing module named _posixshmem - imported by multiprocessing.resource_tracker (conditional), multiprocessing.shared_memory (conditional)
missing module named _scproxy - imported by urllib.request (conditional)
missing module named termios - imported by getpass (optional)
missing module named multiprocessing.BufferTooShort - imported by multiprocessing (top-level), multiprocessing.connection (top-level)
missing module named multiprocessing.AuthenticationError - imported by multiprocessing (top-level), multiprocessing.connection (top-level)
missing module named _frozen_importlib_external - imported by importlib._bootstrap (delayed), importlib (optional), importlib.abc (optional), zipimport (top-level)
excluded module named _frozen_importlib - imported by importlib (optional), importlib.abc (optional), zipimport (top-level)
missing module named posix - imported by os (conditional, optional), posixpath (optional), shutil (conditional), importlib._bootstrap_external (conditional)
missing module named resource - imported by posix (top-level)
missing module named multiprocessing.get_context - imported by multiprocessing (top-level), multiprocessing.pool (top-level), multiprocessing.managers (top-level), multiprocessing.sharedctypes (top-level)
missing module named multiprocessing.TimeoutError - imported by multiprocessing (top-level), multiprocessing.pool (top-level)
missing module named multiprocessing.set_start_method - imported by multiprocessing (top-level), multiprocessing.spawn (top-level)
missing module named multiprocessing.get_start_method - imported by multiprocessing (top-level), multiprocessing.spawn (top-level)
missing module named pyimod02_importers - imported by C:\Users\User\AppData\Local\Programs\Python\Python312\Lib\site-packages\PyInstaller\hooks\rthooks\pyi_rth_pkgutil.py (delayed)
missing module named collections.Callable - imported by collections (optional), socks (optional)
missing module named vms_lib - imported by platform (delayed, optional)
missing module named 'java.lang' - imported by platform (delayed, optional)
missing module named java - imported by platform (delayed)
missing module named _winreg - imported by platform (delayed, optional)
missing module named simplejson - imported by requests.compat (conditional, optional)
missing module named dummy_threading - imported by requests.cookies (optional)
missing module named asyncio.DefaultEventLoopPolicy - imported by asyncio (delayed, conditional), asyncio.events (delayed, conditional)
missing module named annotationlib - imported by typing_extensions (conditional)
missing module named 'h2.events' - imported by urllib3.http2.connection (top-level)
missing module named 'h2.connection' - imported by urllib3.http2.connection (top-level)
missing module named h2 - imported by urllib3.http2.connection (top-level)
missing module named zstandard - imported by urllib3.util.request (optional), urllib3.response (optional)
missing module named brotli - imported by urllib3.util.request (optional), urllib3.response (optional)
missing module named brotlicffi - imported by urllib3.util.request (optional), urllib3.response (optional)
missing module named win_inet_pton - imported by socks (conditional, optional)
missing module named bcrypt - imported by cryptography.hazmat.primitives.serialization.ssh (optional)
missing module named cryptography.x509.UnsupportedExtension - imported by cryptography.x509 (optional), urllib3.contrib.pyopenssl (optional)
missing module named 'OpenSSL.crypto' - imported by urllib3.contrib.pyopenssl (delayed, conditional)
missing module named OpenSSL - imported by urllib3.contrib.pyopenssl (top-level)
missing module named 'pyodide.ffi' - imported by urllib3.contrib.emscripten.fetch (delayed, optional)
missing module named pyodide - imported by urllib3.contrib.emscripten.fetch (top-level)
missing module named js - imported by urllib3.contrib.emscripten.fetch (top-level)
missing module named 'win32com.gen_py' - imported by win32com (conditional, optional)

File diff suppressed because it is too large Load Diff

73
docker-compose.prod.yaml Normal file
View File

@@ -0,0 +1,73 @@
services:
backend:
image: itam-backend:prod
build:
context: .
dockerfile: Dockerfile.backend.prod
container_name: itam-backend
working_dir: /app
env_file:
- .env
environment:
NODE_ENV: production
PORT: 3000
volumes:
- ./uploads:/app/uploads
- ./map_config.json:/app/map_config.json:ro
expose:
- "3000"
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
frontend:
image: itam-frontend:prod
build:
context: .
dockerfile: Dockerfile.frontend.prod
container_name: itam-frontend
expose:
- "80"
restart: unless-stopped
nginx:
image: nginx:stable-alpine
container_name: itam-nginx
ports:
- "9090:80"
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./logs/nginx:/var/log/nginx
depends_on:
backend:
condition: service_healthy
frontend:
condition: service_started
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s
database:
image: mysql:latest
container_name: itam-mysql
ports:
- "3306:3306"
environment:
- MYSQL_ROOT_PASSWORD=itam1234 # 여기 직접 기입
- MYSQL_DATABASE=itam
- MYSQL_USER=itam
- MYSQL_PASSWORD=itam1234
volumes:
- ./mysql_data:/var/lib/mysql
restart: always
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci

62
docker-compose.test.yaml Normal file
View File

@@ -0,0 +1,62 @@
# Local testing compose file - uses relative paths and build contexts
# Usage: docker compose -f docker-compose.test.yaml up --build
services:
backend:
build:
context: .
dockerfile: Dockerfile.backend.prod
container_name: itam-backend-test
working_dir: /app
env_file:
- .env
environment:
NODE_ENV: development
PORT: 3000
DB_HOST: ${DB_HOST:-172.16.8.151}
DB_PORT: ${DB_PORT:-3306}
DB_USER: ${DB_USER:-root}
DB_PASS: ${DB_PASS:-}
DB_NAME: ${DB_NAME:-itam}
ports:
- "3000:3000"
volumes:
- ./uploads:/app/uploads
- ./map_config.json:/app/map_config.json:ro
restart: unless-stopped
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:3000/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 40s
frontend:
build:
context: .
dockerfile: Dockerfile.frontend.prod
container_name: itam-frontend-test
expose:
- "80"
restart: unless-stopped
nginx:
image: nginx:stable-alpine
container_name: itam-nginx-test
ports:
- "8080:80"
volumes:
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
- ./logs/nginx:/var/log/nginx
depends_on:
backend:
condition: service_healthy
frontend:
condition: service_started
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "--quiet", "--tries=1", "--spider", "http://localhost:80/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 20s

48
docker-compose.yaml Normal file
View File

@@ -0,0 +1,48 @@
services:
backend:
build:
context: .
dockerfile: Dockerfile.backend
container_name: dachs-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: dachs-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

@@ -0,0 +1,55 @@
server {
listen 80;
listen [::]:80;
server_name _;
root /usr/share/nginx/html;
# Logging
access_log /var/log/nginx/frontend-access.log main;
error_log /var/log/nginx/frontend-error.log warn;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Gzip compression
gzip on;
gzip_types text/plain text/css text/xml text/javascript
application/x-javascript application/xml+rss
application/json application/javascript;
gzip_min_length 1000;
# Serve static files with SPA fallback
location / {
try_files $uri $uri/ /index.html;
}
# Cache static assets (60 days)
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|webp|woff|woff2|ttf|eot)$ {
expires 60d;
add_header Cache-Control "public, immutable";
}
# Don't cache HTML files
location ~* \.html$ {
expires -1;
add_header Cache-Control "no-cache, no-store, must-revalidate";
}
# Health check
location /health {
access_log off;
return 200 "OK\n";
add_header Content-Type text/plain;
}
# Deny access to sensitive files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
}

View File

@@ -0,0 +1,16 @@
# 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.

101
docker/nginx/default.conf Normal file
View File

@@ -0,0 +1,101 @@
upstream backend {
server backend:3000;
}
upstream frontend {
server frontend:80;
}
server {
listen 80;
listen [::]:80;
server_name _;
# Client upload size limit (adjust as needed)
client_max_body_size 100M;
# Logging
access_log /var/log/nginx/access.log main;
error_log /var/log/nginx/error.log warn;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Gzip compression
gzip on;
gzip_types text/plain text/css text/xml text/javascript
application/x-javascript application/xml+rss
application/json application/javascript;
gzip_min_length 1000;
# Forward all app requests to the frontend container
location / {
proxy_pass http://frontend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_http_version 1.1;
}
# API proxy to backend
location /api/ {
proxy_pass http://backend/api/;
# Preserve original request information
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $server_name;
proxy_set_header X-Forwarded-Port $server_port;
# Connection settings
proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
# Buffering settings
proxy_buffering on;
proxy_buffer_size 4k;
proxy_buffers 8 4k;
proxy_busy_buffers_size 8k;
}
# Uploads proxy to backend
location /uploads/ {
proxy_pass http://backend/uploads/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# Cache uploads
expires 30d;
add_header Cache-Control "public";
}
# Health check endpoint (for monitoring)
location /health {
access_log off;
return 200 "OK\n";
add_header Content-Type text/plain;
}
# Deny access to sensitive files
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
location ~ ~$ {
deny all;
access_log off;
log_not_found off;
}
}

226
docs/itam_cicd_setup.md Normal file
View File

@@ -0,0 +1,226 @@
# ITAM CI/CD 설정 가이드
## 1. 문서 목적
이 문서는 현재 ITAM 저장소에 구성된 CI/CD 파일을 실제 운영에 연결하기 위해 필요한 기준을 정리한 가이드다.
대상 범위는 아래와 같다.
1. Gitea Actions workflow 역할
2. Gitea Variables / Secrets 설정값
3. 운영 서버 배포 디렉토리 기준
4. 운영 영속 경로 기준
5. production deploy 실행 전 확인 사항
---
## 2. 현재 CI/CD 구성
현재 `.gitea/workflows`에는 ITAM 관련 workflow만 남겨둔 상태다.
1. `itam_code_check.yml`
2. `itam_docker_build_check.yml`
3. `itam_production_deploy.yml`
각 workflow의 역할은 아래와 같다.
1. `itam_code_check.yml`: TypeScript/Vite build와 compose 문법 검증
2. `itam_docker_build_check.yml`: 운영용 Docker 이미지 빌드 가능 여부 검증
3. `itam_production_deploy.yml`: 운영 서버에 SSH 접속 후 실제 배포 수행
현재 배포 흐름은 아래와 같다.
```mermaid
flowchart LR
DEV["Developer Push or Manual Run"] --> CODE["ITAM Code Check"]
CODE --> BUILD["ITAM Docker Build Check"]
BUILD --> DEPLOY["ITAM Production Deploy"]
DEPLOY --> HOST["Production Host"]
HOST --> APP["docker compose up -d --build"]
linkStyle default stroke:#d32f2f,stroke-width:2px;
```
---
## 3. Gitea Variables / Secrets 기준
`itam_production_deploy.yml`이 정상 동작하려면 아래 값이 필요하다.
### 3.1 Variables
아래 항목은 Gitea repository Variables에 등록한다.
| Key | 설명 | 예시 |
| --- | --- | --- |
| `PROD_HOST` | 운영 서버 SSH 접속 호스트 | `10.0.0.25` |
| `PROD_USER` | 운영 서버 SSH 사용자 | `deploy` |
| `PROD_DEPLOY_PATH` | 서버에서 저장소를 배포할 경로 | `/opt/itam` |
| `PROD_BACKUP_ROOT` | 배포 전 백업 저장 경로, 배포 경로 바깥이어야 함 | `/opt/itam-backups` |
| `PROD_GIT_URL` | 운영 서버에서 pull 가능한 저장소 주소 | `git@gitea.example.com:team/itam.git` |
| `PROD_DB_HOST` | 외부 MySQL 호스트 | `172.16.8.151` |
| `PROD_DB_PORT` | 외부 MySQL 포트 | `3306` |
| `PROD_DB_USER` | 운영 DB 계정 | `itam_admin` |
| `PROD_DB_NAME` | 운영 DB 이름 | `itam` |
| `PROD_LOG_LEVEL` | 애플리케이션 로그 레벨 | `info` |
### 3.2 Secrets
아래 항목은 Gitea repository Secrets에 등록한다.
| Key | 설명 |
| --- | --- |
| `PROD_SSH_PRIVATE_KEY` | 운영 서버 접속용 개인키 |
| `PROD_DB_PASS` | 운영 DB 비밀번호 |
### 3.3 운영 원칙
1. `PROD_DB_PASS`는 Variables가 아니라 Secrets에만 둔다.
2. `PROD_SSH_PRIVATE_KEY`는 배포 전용 계정 키를 사용한다.
3. `PROD_GIT_URL`은 운영 서버에서 직접 pull 가능한 주소여야 한다.
4. 운영 서버의 `known_hosts`는 workflow에서 자동 등록되지만, 최초 운영 전 수동 접속 검증도 함께 수행하는 것이 안전하다.
5. `PROD_BACKUP_ROOT``PROD_DEPLOY_PATH` 내부가 아니라 바깥 경로를 사용해야 한다.
---
## 4. 운영 서버 배포 디렉토리 기준
현재 `itam_production_deploy.yml`은 운영 서버에서 아래 흐름으로 배포를 수행한다.
1. `PROD_DEPLOY_PATH` 디렉토리를 생성한다.
2. 기존 운영 상태가 있으면 배포 전 백업을 수행한다.
3. 해당 경로에 저장소를 clone 또는 fetch 한다.
4. 지정 브랜치로 checkout 한다.
5. `uploads`, `logs/nginx` 디렉토리를 생성한다.
6. `.env.deploy`를 서버의 `.env`로 복사한다.
7. `docker compose -f docker-compose.prod.yaml up -d --build`를 실행한다.
권장 디렉토리 구조는 아래와 같다.
```text
/opt/itam/
.env
docker-compose.prod.yaml
Dockerfile.frontend.prod
Dockerfile.backend.prod
map_config.json
uploads/
logs/
nginx/
docker/
nginx/
default.conf
frontend/
default.conf
src/
public/
img/
/opt/itam-backups/
db/
files/
```
현재 구조 기준 배포 관계는 아래와 같다.
```mermaid
flowchart TB
subgraph HOST["Production Host"]
REPO["PROD_DEPLOY_PATH"]
ENV[".env"]
UP["uploads/"]
LOGS["logs/nginx/"]
MAP["map_config.json"]
end
subgraph CTR["Docker Services"]
NGINX["itam-nginx"]
FRONT["itam-frontend"]
BACK["itam-backend"]
end
DB["External MySQL"]
REPO --> NGINX
ENV --> BACK
UP --> BACK
MAP --> BACK
LOGS --> NGINX
REPO --> BAK["PROD_BACKUP_ROOT"]
NGINX --> FRONT
NGINX --> BACK
BACK --> DB
linkStyle default stroke:#d32f2f,stroke-width:2px;
```
---
## 5. 영속 경로 기준
현재 `docker-compose.prod.yaml` 기준으로 운영에서 유지되어야 하는 경로는 아래와 같다.
1. `.env`
2. `uploads/`
3. `map_config.json`
4. `logs/nginx/`
5. `PROD_BACKUP_ROOT`
각 경로의 의미는 아래와 같다.
1. `.env`: backend 런타임 환경변수
2. `uploads/`: 업로드 파일 데이터
3. `map_config.json`: 위치/맵 구성 데이터
4. `logs/nginx/`: reverse proxy 접근 로그 및 에러 로그
5. `PROD_BACKUP_ROOT`: 배포 전 DB dump와 운영 파일 아카이브 저장 위치
운영 기준으로 보면 `uploads/``map_config.json`은 애플리케이션 데이터이고, `.env`는 환경 설정이며, `logs/nginx/`는 운영 추적 데이터다.
즉 서버 운영 시 컨테이너만 다시 띄우면 되는 구조가 아니라, 이 경로들이 유지되는 것을 전제로 배포가 성립한다.
---
## 6. 배포 전 체크리스트
`itam_production_deploy.yml` 실행 전 아래 항목을 먼저 확인한다.
1. 운영 서버에 Docker Engine과 `docker compose`가 설치되어 있어야 한다.
2. 운영 서버의 배포 계정이 Docker 실행 권한을 가져야 한다.
3. 운영 서버에서 `PROD_GIT_URL`로 직접 `git fetch`가 가능해야 한다.
4. 외부 MySQL 접속 정보가 실제 운영망 기준으로 열려 있어야 한다.
5. 운영 서버에 `map_config.json` 초기 파일이 존재해야 한다.
6. 방화벽 또는 보안 장비에서 80 포트 접근 정책이 정리되어 있어야 한다.
권장 확인 명령 예시는 아래와 같다.
```bash
docker --version
docker compose version
git ls-remote <PROD_GIT_URL>
test -f map_config.json
test -d uploads
```
---
## 7. 현재 구조에서의 해석
현재 ITAM CI/CD는 staging 없이도 운영 배포가 가능한 최소 구조로 정리되어 있다.
이 구조의 장점은 아래와 같다.
1. workflow 수가 적어서 관리가 단순하다.
2. 운영 배포에 필요한 변수와 시크릿 범위가 명확하다.
3. staging이 필요해지면 production deploy workflow를 복제해 별도 환경으로 확장하기 쉽다.
즉, 지금 단계에서는 production 기준을 먼저 고정하고, staging은 동일 패턴으로 추후 추가하는 전략이 적절하다.
---
## 8. 다음 권장 작업
현재 문서 기준으로 바로 이어서 할 작업은 아래 순서가 적절하다.
1. `PROD_DEPLOY_PATH` 실제 서버 경로 확정
2. 운영 서버 배포 계정 생성 및 SSH 키 등록
3. `map_config.json`, `uploads/` 초기 데이터 준비
4. production deploy workflow에 smoke check 추가
5. 로그 로테이션과 백업/복구 절차 문서화

View File

@@ -1,51 +0,0 @@
# 구조 개선 및 다중 탭(Depth 2) 도입 계획
사용자 요청에 따라 H/W와 S/W를 구분하고, 그 하위에 각각 대시보드 및 상세 항목(개인PC, 서버 등) 탭을 나누는 네비게이션 구조를 도입합니다. 바닐라 JS 기반에서 각 탭마다 다른 데이터 테이블을 그려내는 아키텍처로 개선합니다.
## User Review Required
> [!IMPORTANT]
> 1. **엑셀 관리 방식 (Sheets 분리)**: 단일 엑셀 파일 안에 여러 개의 시트(Sheet)를 나누어 관리하는 방식으로 제안합니다. 한 번 엑셀을 업로드하면, `개인PC`, `서버`, `스토리지`, `전산비품` 등 각각의 시트를 한방에 파싱하여 각 탭에 적용하도록 구성하겠습니다.
> 2. **S/W 스키마**: 현재 H/W 기반 데이터 스키마만 정의되어 있습니다. [구독 소프트웨어]와 [영구 소프트웨어] 탭 개발을 위한 데이터 항목들(예: 사용기간, 라이선스키, 결제방식 등)은 아직 정해지지 않았으므로 일단 공통 S/W 데이터 스키마 임시 템플릿(S/W명, 유형, 라이선스키, 할당된 사용자 등)으로 만들어 두고 추후 수정할 수 있도록 개발해도 될까요?
## Proposed Changes
### 1. UI/UX: 2 Depth 네비게이션 (`index.html`, `style.css`)
- **좌측(또는 상단) GNB (Global Navigation Bar)**: H/W 와 S/W 를 스위치할 수 있는 메인 탭 생성.
- **LNB (Local Navigation Bar)**: 메인 탭 전환 시 나타나는 서브 탭(H/W: 대시보드/PC/서버/스토리지/비품, S/W: 대시보드/구독/영구).
- `README.md` 가이드라인에 따라 화면을 분할하고 정보 밀도를 높이기 위해 Box-less, Line-based Layout 유지.
#### [MODIFY] index.html
#### [MODIFY] src/style.css
---
### 2. 다중 데이터 구조 및 상태 관리 (`main.ts`)
- 현재 선택된 메뉴 뎁스(예: `activeCategory = 'HW'`, `activeSubTab = '개인PC'`)에 따라 렌더링 함수가 동기화되도록 라우팅/상태 관리 로직 추가.
- `Dashboard` 탭 진입 시, 모든 서브 탭 데이터의 갯수(Total PCs, Total Servers 등)를 한눈에 볼 수 있는 요약 영역(Summary Cards/Charts 영역) 예약 및 구현.
#### [MODIFY] src/main.ts
---
### 3. 멀티-시트(Multi-sheet) 엑셀 파싱 (`excelHandler.ts`)
- `SheetJS` 기능을 확장하여 다운로드/데이터 추출 시 다중 시트 생성.
- **H/W 템플릿 시트명**: `[개인PC, 서버, 스토리지, 전산비품]`
- **S/W 템플릿 시트명**: `[구독SW, 영구SW]`
#### [MODIFY] src/excelHandler.ts
## Open Questions
> [!WARNING]
> * 왼쪽 사이드바로 메뉴를 구성하는 것이 좋을까요, 상단 가로바(Top Nav) 2단으로 구성하는 것이 좋을까요? Reference 이미지가 따로 없다면 범용적으로 관리하기 편한 **왼쪽 사이드바 구조(Sidebar Menu)** 를 제안합니다. (진행 승인 시 사이드바 형태로 구현합니다.)
## Verification Plan
### Automated Tests
- 좌측 `H/W`, `S/W` 클릭 시 서브 메뉴가 정상 토글되는지 검증(`main.ts` DOM class toggle 확인).
- 서브 메뉴 `서버` 클릭 시 빈 테이블(또는 서버 자산 테이블)이 그려지는지 확인.
- 달라진 구조로 `엑셀 템플릿 양식`을 다운로드했을 때 파일에 다수의 시트(Sheet)가 정상 분류되어 있는지 확인.
### Manual Verification
- 브라우저 에이전트를 통해 바뀐 화면의 스크린샷(LNB 사이드바, Dashboard 화면 등)을 찍어 사용자에게 보고.

View File

@@ -1,47 +0,0 @@
# 임시 DB 생성 및 S/W 사용자 관리 개편
임시 DB 엑셀 파일 생성과 S/W 목록의 '할당자' 속성 UI 개편에 대한 기술 구현 계획입니다.
## User Review Required
> [!IMPORTANT]
> **사용자 관리 데이터 저장 방식에 대한 피드백이 필요합니다.**
> 엑셀을 임시 DB로 사용하고 있기 때문에, "사용자 관리" 팝업에서 추가/삭제된 사용자 목록을 엑셀에 저장할 때 **쉼표(,)로 구분된 하나의 문자열**(예: `홍길동, 김철수, 이영희`)로 기존 `할당자` 컬럼에 업데이트 하는 방식을 제안합니다. 이 방식이 괜찮으신가요?
## Proposed Changes
### 1. 임시 DB 연동
임시로 사용할 초기 엑셀 파일(`temp_db.xlsx`)을 프로젝트 루트에 스크립트를 통해 생성합니다.
- 개인PC, 서버, 구독SW, 영구SW 시트에 각각 구성을 확인할 수 있는 dummy 데이터 1~2개씩을 포함하여 생성합니다.
- 향후 화면에서 '엑셀 업로드'를 통해 이 파일을 업로드하여 데이터를 화면에 뿌려볼 수 있습니다. (원하시면 페이지 로드 시 이 파일을 임포트하도록 로직을 변경할 수도 있으나, 브라우저 단에서 로컬 파일을 자동 리딩하는 것은 제한이 있으므로 기본적으로는 파일을 제공만 합니다.)
---
### 2. 컴포넌트: HTML 구조 변경
#### [MODIFY] [index.html](file:///c:/Project/HM%20ITAM/index.html)
- `sw-asset-modal`의 폼 내용 중 "할당자" 입력 폼(<label> 및 <input>) 제거
- 관리 팝업을 위한 `sw-user-modal` 모달 오버레이 마크업 추가
기존 유저 목록을 보여주고, 새 사용자를 추가하거나 기존 사용자를 삭제할 수 있는 UI (리스트, 추가 인풋, 추가 버튼 기반) 작성
---
### 3. 컴포넌트: 로직 및 스타일
#### [MODIFY] [src/main.ts](file:///c:/Project/HM%20ITAM/src/main.ts)
- S/W 렌더링 영역(`renderTable`)에서 데스크탑 뷰의 `<th>할당자</th>` 및 해당하는 셀(`<td>`) 제거
- S/W `관리` 탭(`<td>`)에 수정 버튼(`btn-edit`) 옆에 사용자 관리 아이콘 (Lucide의 `Users` 또는 `UserCog` 아이콘 활용) 추가
- 사용자 관리 아이콘 클릭 시 `sw-user-modal` 팝업 띄우는 이벤트 리스너 추가
- `sw-user-modal` 팝업 내에서 사용자를 추가/삭제하고 '저장' 시, 해당 S/W 자산의 `할당자` 데이터를 갱신하도록 처리 (쉼표 구분 형태)
#### [MODIFY] [src/excelHandler.ts](file:///c:/Project/HM%20ITAM/src/excelHandler.ts)
- (선택 사항) `SW_HEADERS`나 엑셀 파싱 로직은 그대로 두어 하위 호환성 유지. 사용자가 데이터를 쉼표 형태로 주고 받을 것이므로 별도의 인터페이스 변경은 없음.
## Open Questions
- 사용자 관리 팝업에서 저장할 때, 이름 말고 '부서'나 '직급' 같은 추가적인 정보도 관리가 필요하신가요? (기본적으로는 엑셀에 단일 텍스트로 보존되므로 '이름'만 관리하는 것으로 설계했습니다.)
- 개발 환경(Vite)에서 초기 로딩 시 `temp_db.xlsx`를 자동으로 불러오도록 Vite의 플러그인 또는 fetch 로직을 추가하는 것을 원하시나요? 아니면 엑셀 파일만 만들어 드리고 사용자가 '엑셀 업로드' 버튼으로 직접 연동해 쓰는 방식이 좋으신가요?
## Verification Plan
### Manual Verification
1. `npm run dev` 후 브라우저 접속
2. 프로젝트 폴더에 `temp_db.xlsx` 파일이 생성되었는지 확인
3. 소프트웨어 > 영구/구독 탭 진입 시 "할당자" 테이블 헤더가 사라진 것 확인
4. 관리 탭의 "사용자 관리" 아이콘 클릭 시, 해당 소프트웨어의 사용자를 등록하고 삭제할 수 있는 팝업 등장하는지 확인
5. 사용자 아이콘을 클릭해 홍길동, 김철수 등록 후, 전체 엑셀 저장 혹은 다운로드 시 엑셀 파일 내의 '할당자' 열에 `홍길동,김철수` 로 잘 들어가는지 확인

View File

@@ -10,6 +10,7 @@
href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" />
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chartjs-plugin-datalabels@2.0.0"></script>
<script src="/qrcode.min.js"></script>
</head>
<body>

View File

@@ -112,7 +112,7 @@
"y": "32.01",
"w": "40.87",
"h": "6.24",
"asset_id": null
"asset_id": "9pvkqyi"
}
],
"img/location_photo/IDC/서관203.png": [
@@ -722,5 +722,21 @@
"h": "6.75",
"asset_id": "server_1779761946023_64"
}
],
"img/location_photo/TDD_TEST_MAP.png": [
{
"x": "30.50",
"y": "40.25",
"w": "10.00",
"h": "12.00",
"asset_id": null
},
{
"x": "50.00",
"y": "60.00",
"w": "5.00",
"h": "5.00",
"asset_id": null
}
]
}

View File

@@ -5,6 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ITAM Map Coordinate Editor v3.0</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" />
<script src="/qrcode.min.js"></script>
</head>
<body class="editor-body">
@@ -30,8 +31,9 @@
<div class="box-list" id="box-list"></div>
<div class="actions">
<div class="actions" style="display: flex; flex-direction: column; gap: 0.5rem;">
<button id="btn-clear-all" class="btn btn-outline">전체 삭제</button>
<button id="btn-print-map-qrs" class="btn btn-outline btn-primary">이 도면 QR 일괄인쇄</button>
<button id="btn-save-server" class="btn btn-primary">서버에 즉시 저장</button>
<div id="save-status"></div>
</div>

299
mobile.html Normal file
View File

@@ -0,0 +1,299 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>ITAM 모바일 실사 점검</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard@v1.3.9/dist/web/variable/pretendardvariable.min.css" />
<script src="https://unpkg.com/html5-qrcode@2.3.8/html5-qrcode.min.js"></script>
<style>
:root {
--bg: #09090b;
--card: #18181b;
--card-border: #27272a;
--primary: #3b82f6;
--primary-hover: #2563eb;
--success: #10b981;
--danger: #ef4444;
--text: #f4f4f5;
--text-muted: #a1a1aa;
--font-family: 'Pretendard Variable', -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: var(--font-family);
}
body {
background-color: var(--bg);
color: var(--text);
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
header {
background-color: var(--card);
border-bottom: 1px solid var(--card-border);
padding: 1rem;
text-align: center;
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
header h1 {
font-size: 1.1rem;
font-weight: 700;
background: linear-gradient(135deg, #60a5fa, #3b82f6);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background-color: var(--success);
box-shadow: 0 0 8px var(--success);
}
main {
flex: 1;
display: flex;
flex-direction: column;
padding: 1rem;
gap: 1rem;
overflow-y: auto;
align-items: center;
justify-content: center;
}
/* Scanner Viewport */
.scanner-container {
width: 100%;
max-width: 400px;
aspect-ratio: 1;
border-radius: 16px;
overflow: hidden;
border: 2px dashed var(--card-border);
position: relative;
background-color: #000;
}
#reader {
width: 100% !important;
height: 100% !important;
border: none !important;
}
#reader video {
object-fit: cover !important;
width: 100% !important;
height: 100% !important;
}
/* Scan Laser Line Animation */
.scan-laser {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 2px;
background: linear-gradient(to right, transparent, var(--primary), transparent);
animation: scan 2s linear infinite;
z-index: 10;
pointer-events: none;
}
@keyframes scan {
0% { top: 0%; }
50% { top: 100%; }
100% { top: 0%; }
}
/* Bottom Info Card */
.info-panel {
width: 100%;
max-width: 400px;
background-color: var(--card);
border: 1px solid var(--card-border);
border-radius: 16px;
padding: 1rem;
display: flex;
flex-direction: column;
gap: 0.75rem;
flex-shrink: 0;
}
.info-section {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.info-label {
font-size: 0.75rem;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
.info-value {
font-size: 0.95rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
min-height: 24px;
}
.badge-lock {
background-color: rgba(59, 130, 246, 0.15);
color: var(--primary);
padding: 0.25rem 0.5rem;
border-radius: 6px;
font-size: 0.8rem;
border: 1px solid rgba(59, 130, 246, 0.3);
font-weight: 700;
}
.badge-empty {
color: var(--text-muted);
font-weight: 400;
font-style: italic;
}
.btn-action {
background-color: var(--primary);
color: var(--text);
border: none;
padding: 0.5rem 1rem;
border-radius: 8px;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
transition: background-color 0.2s;
}
.btn-action:hover {
background-color: var(--primary-hover);
}
.btn-action.btn-danger {
background-color: rgba(239, 68, 68, 0.15);
color: var(--danger);
border: 1px solid rgba(239, 68, 68, 0.3);
}
.btn-action.btn-danger:hover {
background-color: rgba(239, 68, 68, 0.25);
}
/* Manual Input Section */
.manual-toggle {
text-align: center;
font-size: 0.8rem;
color: var(--primary);
cursor: pointer;
text-decoration: underline;
}
.manual-form {
display: none;
flex-direction: column;
gap: 0.5rem;
width: 100%;
}
.input-field {
width: 100%;
background-color: var(--bg);
border: 1px solid var(--card-border);
border-radius: 8px;
color: var(--text);
padding: 0.5rem;
font-size: 0.9rem;
}
.input-field:focus {
outline: 1px solid var(--primary);
}
/* Feedbacks Overlay */
.feedback-message {
text-align: center;
padding: 0.5rem;
border-radius: 8px;
font-size: 0.85rem;
display: none;
animation: fadeIn 0.3s;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(5px); }
to { opacity: 1; transform: translateY(0); }
}
.feedback-success {
background-color: rgba(16, 185, 129, 0.15);
color: var(--success);
border: 1px solid rgba(16, 185, 129, 0.3);
}
.feedback-error {
background-color: rgba(239, 68, 68, 0.15);
color: var(--danger);
border: 1px solid rgba(239, 68, 68, 0.3);
}
</style>
</head>
<body>
<header>
<h1>ITAM 모바일 실사</h1>
<div class="status-dot"></div>
</header>
<main>
<div class="scanner-container">
<div id="reader"></div>
<div class="scan-laser"></div>
</div>
<div class="info-panel">
<!-- 1. 위치 락 정보 -->
<div class="info-section">
<span class="info-label">현재 점검 위치 (Location)</span>
<div class="info-value">
<span id="loc-display" class="badge-empty">위치 QR 코드를 먼저 스캔하세요.</span>
<button id="btn-unlock-loc" class="btn-action btn-danger" style="display: none;">해제</button>
</div>
</div>
<hr style="border: 0; border-top: 1px solid var(--card-border); margin: 0.25rem 0;" />
<!-- 2. 자산 스캔 결과 및 피드백 -->
<div id="scan-feedback" class="feedback-message"></div>
<!-- 3. 수동 입력 토글 및 양식 -->
<div class="info-section">
<span id="btn-toggle-manual" class="manual-toggle">카메라가 안 되나요? 수동 코드로 입력</span>
<div id="manual-form" class="manual-form">
<input type="text" id="manual-code-input" class="input-field" placeholder="위치 또는 자산 코드 입력" />
<button id="btn-submit-manual" class="btn-action w-full">입력 확인</button>
</div>
</div>
</div>
</main>
<script type="module" src="/src/mobile-main.ts"></script>
</body>
</html>

291
package-lock.json generated
View File

@@ -14,9 +14,11 @@
"iconv-lite": "^0.7.2",
"lucide": "^0.364.0",
"mysql2": "^3.22.1",
"qrcode": "^1.5.4",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@types/qrcode": "^1.5.6",
"typescript": "^5.2.2",
"vite": "^5.2.0"
}
@@ -774,11 +776,19 @@
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~7.19.0"
}
},
"node_modules/@types/qrcode": {
"version": "1.5.6",
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
"dev": true,
"dependencies": {
"@types/node": "*"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz",
@@ -801,6 +811,28 @@
"node": ">=0.8"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"engines": {
"node": ">=8"
}
},
"node_modules/ansi-styles": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dependencies": {
"color-convert": "^2.0.1"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/aws-ssl-profiles": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
@@ -872,6 +904,14 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/camelcase": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
"engines": {
"node": ">=6"
}
},
"node_modules/cfb": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
@@ -885,6 +925,16 @@
"node": ">=0.8"
}
},
"node_modules/cliui": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.0",
"wrap-ansi": "^6.2.0"
}
},
"node_modules/codepage": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
@@ -894,6 +944,22 @@
"node": ">=0.8"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz",
@@ -980,6 +1046,14 @@
}
}
},
"node_modules/decamelize": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/denque": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
@@ -998,6 +1072,11 @@
"node": ">= 0.8"
}
},
"node_modules/dijkstrajs": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="
},
"node_modules/dotenv": {
"version": "17.4.2",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz",
@@ -1030,6 +1109,11 @@
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
@@ -1187,6 +1271,18 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
"dependencies": {
"locate-path": "^5.0.0",
"path-exists": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
@@ -1247,6 +1343,14 @@
"is-property": "^1.0.2"
}
},
"node_modules/get-caller-file": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
@@ -1371,6 +1475,14 @@
"node": ">= 0.10"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"engines": {
"node": ">=8"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz",
@@ -1383,6 +1495,17 @@
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
"license": "MIT"
},
"node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
"dependencies": {
"p-locate": "^4.1.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/long": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz",
@@ -1575,6 +1698,39 @@
"wrappy": "1"
}
},
"node_modules/p-limit": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
"dependencies": {
"p-try": "^2.0.0"
},
"engines": {
"node": ">=6"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-locate": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
"dependencies": {
"p-limit": "^2.2.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
"engines": {
"node": ">=6"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
@@ -1584,6 +1740,14 @@
"node": ">= 0.8"
}
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
"engines": {
"node": ">=8"
}
},
"node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
@@ -1601,6 +1765,14 @@
"dev": true,
"license": "ISC"
},
"node_modules/pngjs": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/postcss": {
"version": "8.5.9",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.9.tgz",
@@ -1643,6 +1815,22 @@
"node": ">= 0.10"
}
},
"node_modules/qrcode": {
"version": "1.5.4",
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
"dependencies": {
"dijkstrajs": "^1.0.1",
"pngjs": "^5.0.0",
"yargs": "^15.3.1"
},
"bin": {
"qrcode": "bin/qrcode"
},
"engines": {
"node": ">=10.13.0"
}
},
"node_modules/qs": {
"version": "6.15.1",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
@@ -1682,6 +1870,19 @@
"node": ">= 0.10"
}
},
"node_modules/require-directory": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/require-main-filename": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="
},
"node_modules/rollup": {
"version": "4.60.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz",
@@ -1794,6 +1995,11 @@
"url": "https://opencollective.com/express"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -1918,6 +2124,30 @@
"node": ">= 0.8"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@ -1959,8 +2189,7 @@
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/unpipe": {
"version": "1.0.0",
@@ -2040,6 +2269,11 @@
}
}
},
"node_modules/which-module": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="
},
"node_modules/wmf": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
@@ -2058,6 +2292,19 @@
"node": ">=0.8"
}
},
"node_modules/wrap-ansi": {
"version": "6.2.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
@@ -2084,6 +2331,44 @@
"engines": {
"node": ">=0.8"
}
},
"node_modules/y18n": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="
},
"node_modules/yargs": {
"version": "15.4.1",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
"dependencies": {
"cliui": "^6.0.0",
"decamelize": "^1.2.0",
"find-up": "^4.1.0",
"get-caller-file": "^2.0.1",
"require-directory": "^2.1.1",
"require-main-filename": "^2.0.0",
"set-blocking": "^2.0.0",
"string-width": "^4.2.0",
"which-module": "^2.0.0",
"y18n": "^4.0.0",
"yargs-parser": "^18.1.2"
},
"engines": {
"node": ">=8"
}
},
"node_modules/yargs-parser": {
"version": "18.1.3",
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
"dependencies": {
"camelcase": "^5.0.0",
"decamelize": "^1.2.0"
},
"engines": {
"node": ">=6"
}
}
}
}

View File

@@ -11,6 +11,7 @@
"db-init": "node db_init.js"
},
"devDependencies": {
"@types/qrcode": "^1.5.6",
"typescript": "^5.2.2",
"vite": "^5.2.0"
},
@@ -21,6 +22,7 @@
"iconv-lite": "^0.7.2",
"lucide": "^0.364.0",
"mysql2": "^3.22.1",
"qrcode": "^1.5.4",
"xlsx": "^0.18.5"
}
}

1
public/qrcode.min.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -1,24 +0,0 @@
const mysql = require('mysql2/promise');
require('dotenv').config();
async function analyzeCodes() {
const connection = await mysql.createConnection({
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 [years] = await connection.query('SELECT DISTINCT purchase_date FROM asset_core WHERE id LIKE "PC_20260615_%"');
console.log('New assets years:', years.map(y => y.purchase_date));
// 기존 자산 코드 패턴 확인
const [existing] = await connection.query('SELECT asset_code FROM asset_core WHERE asset_code LIKE "PC-%" LIMIT 5');
console.log('Existing code sample:', existing);
await connection.end();
}
analyzeCodes().catch(console.error);

View File

@@ -1,11 +0,0 @@
const XLSX = require('xlsx');
const workbook = XLSX.readFile('backupDB_20260602.xlsx');
console.log('Sheet Names:', workbook.SheetNames);
if (workbook.SheetNames.includes('system_users')) {
const sheet = workbook.Sheets['system_users'];
const data = XLSX.utils.sheet_to_json(sheet);
console.log('system_users found! Count:', data.length);
console.log('Sample:', data.slice(0, 2));
} else {
console.log('system_users sheet not found in backupDB_20260602.xlsx');
}

View File

@@ -1,24 +0,0 @@
const mysql = require('mysql2/promise');
require('dotenv').config();
async function checkCodes() {
const connection = await mysql.createConnection({
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')
});
console.log('--- Asset Codes Sample ---');
const [rows] = await connection.query('SELECT id, asset_code, purchase_date FROM asset_core WHERE id LIKE "PC_20260615_%" LIMIT 10');
console.log(rows);
console.log('\n--- Other Asset Codes Sample ---');
const [rows2] = await connection.query('SELECT id, asset_code, purchase_date FROM asset_core WHERE id NOT LIKE "PC_20260615_%" AND asset_code IS NOT NULL LIMIT 5');
console.log(rows2);
await connection.end();
}
checkCodes().catch(console.error);

View File

@@ -1,40 +0,0 @@
const mysql = require('mysql2/promise');
require('dotenv').config();
async function checkPublicPCs() {
const connection = await mysql.createConnection({
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')
});
console.log('🔍 공용 PC(Public PC)로 추정되는 자산 조회 중...');
// 사번이 없거나, 사용자명에 '공용'이 포함된 데이터 조회
const [rows] = await connection.query(`
SELECT id, asset_code, user_current, emp_no, current_dept, asset_type
FROM asset_core
WHERE (emp_no IS NULL OR emp_no = '' OR user_current LIKE '%공용%')
AND id LIKE 'PC_20260615_%'
`);
console.log(`📊 발견된 공용 PC 후보: ${rows.length}`);
if (rows.length > 0) {
console.table(rows.slice(0, 20)); // 상위 20개 샘플 출력
// 요약 통계
const summary = {
only_no_emp: rows.filter(r => (!r.emp_no) && !r.user_current.includes('공용')).length,
only_public_name: rows.filter(r => r.emp_no && r.user_current.includes('공용')).length,
both: rows.filter(r => (!r.emp_no) && r.user_current.includes('공용')).length
};
console.log('\n📈 요약 통계:', summary);
}
await connection.end();
}
checkPublicPCs().catch(console.error);

View File

@@ -1,77 +0,0 @@
const mysql = require('mysql2/promise');
require('dotenv').config();
async function updateAndCompare() {
const connection = await mysql.createConnection({
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')
});
console.log('🚀 [Step 1 & 2] "undefined" 사번 및 빈 사용자명 정리 중...');
const [updateResult] = await connection.query(`
UPDATE asset_core
SET user_current = '공용', emp_no = NULL
WHERE id LIKE "PC_20260615_%" AND (emp_no = 'undefined' OR emp_no IS NULL OR emp_no = '')
`);
console.log(`✅ 업데이트 완료: ${updateResult.affectedRows}`);
console.log('\n🔍 [Step 3] 엑셀 데이터와 DB asset_type 비교 분석 중...');
const XLSX = require('xlsx');
const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx');
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const excelData = XLSX.utils.sheet_to_json(sheet);
// DB 데이터 로드
const [dbRows] = await connection.query('SELECT id, asset_type, user_current, emp_no FROM asset_core WHERE id LIKE "PC_20260615_%"');
const dbMap = new Map();
dbRows.forEach(r => dbMap.set(r.id, r));
const mismatches = [];
const publicButExcelPersonal = [];
for (let i = 0; i < excelData.length; i++) {
const excelRow = excelData[i];
const assetId = `PC_20260615_${String(i + 1).padStart(4, '0')}`;
const dbRow = dbMap.get(assetId);
if (!dbRow) continue;
const excelType = excelRow.asset_type || '개인PC';
// 1. 단순 타입 불일치 체크
if (dbRow.asset_type !== excelType) {
mismatches.push({
id: assetId,
excel_type: excelType,
db_type: dbRow.asset_type,
user: dbRow.user_current
});
}
// 2. 엑셀은 '개인PC'인데 데이터는 공용(사번없음)인 경우 탐색
if (excelType === '개인PC' && (!dbRow.emp_no || dbRow.user_current === '공용')) {
publicButExcelPersonal.push({
id: assetId,
excel_user: excelRow.user_current,
excel_dept: excelRow.current_dept,
db_user: dbRow.user_current
});
}
}
console.log(`\n📊 분석 결과:`);
console.log(`- 엑셀과 DB의 asset_type 불일치: ${mismatches.length}`);
console.log(`- 엑셀은 '개인PC'이나 사번이 없어 '공용'으로 잡힌 항목: ${publicButExcelPersonal.length}`);
if (publicButExcelPersonal.length > 0) {
console.log('\n⚠ 엑셀은 개인PC이나 데이터가 미비한 항목 (상위 10개):');
console.table(publicButExcelPersonal.slice(0, 10));
}
await connection.end();
}
updateAndCompare().catch(console.error);

View File

@@ -1,25 +0,0 @@
const mysql = require('mysql2/promise');
require('dotenv').config();
async function debugPublic() {
const connection = await mysql.createConnection({
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 [rows] = await connection.query(`
SELECT user_current, emp_no, COUNT(*) as count
FROM asset_core
WHERE id LIKE "PC_20260615_%"
GROUP BY user_current, emp_no
HAVING emp_no IS NULL OR emp_no = '' OR user_current LIKE '%공용%' OR user_current = ''
`);
console.table(rows);
await connection.end();
}
debugPublic().catch(console.error);

View File

@@ -1,69 +0,0 @@
const XLSX = require('xlsx');
const mysql = require('mysql2/promise');
require('dotenv').config();
async function deepAudit() {
const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx');
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const excelData = XLSX.utils.sheet_to_json(sheet);
console.log('📊 [Excel Audit] Total Rows:', excelData.length);
// 1. 엑셀 내 asset_type 종류 확인
const excelTypes = new Set();
excelData.forEach(r => excelTypes.add(r.asset_type));
console.log('Excel Asset Types:', Array.from(excelTypes));
// 2. '공용' 키워드가 들어간 모든 행 추출
const publicKeywords = ['공용', '공통', '테스트', 'TEST'];
const potentialPublicInExcel = excelData.filter(r => {
const name = String(r.user_current || '');
const type = String(r.asset_type || '');
const memo = String(r.memo || '');
return publicKeywords.some(k => name.includes(k) || type.includes(k) || memo.includes(k)) || !r.emp_no;
});
console.log(`\n🔍 [Potential Public/Issue Rows in Excel]: ${potentialPublicInExcel.length}`);
console.table(potentialPublicInExcel.slice(0, 30).map(r => ({
emp_no: r.emp_no,
user: r.user_current,
dept: r.current_dept,
type: r.asset_type,
memo: r.memo
})));
// 3. DB와 대조 (특히 엑셀엔 사번이 있는데 DB엔 공용으로 된 게 있는지)
const connection = await mysql.createConnection({
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 [dbRows] = await connection.query('SELECT id, user_current, emp_no, asset_type FROM asset_core WHERE id LIKE "PC_20260615_%"');
// 엑셀은 개인PC인데 DB는 공용인 경우 (또는 그 반대)
const issues = [];
for (let i = 0; i < excelData.length; i++) {
const ex = excelData[i];
const id = `PC_20260615_${String(i + 1).padStart(4, '0')}`;
const db = dbRows.find(r => r.id === id);
if (!db) continue;
const isExcelPublic = !ex.emp_no || String(ex.user_current).includes('공용');
const isDbPublic = !db.emp_no || String(db.user_current).includes('공용');
if (isExcelPublic !== isDbPublic) {
issues.push({ id, excel_user: ex.user_current, db_user: db.user_current, excel_emp: ex.emp_no, db_emp: db.emp_no });
}
}
console.log(`\n⚠️ [Consistency Issues]: ${issues.length}`);
if (issues.length > 0) console.table(issues);
await connection.end();
}
deepAudit().catch(console.error);

View File

@@ -1,61 +0,0 @@
const XLSX = require('xlsx');
const mysql = require('mysql2/promise');
const dotenv = require('dotenv');
const path = require('path');
dotenv.config({ path: path.join(__dirname, '../.env') });
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function extractFailures() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('🔍 실패 데이터 추출 중...');
const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx');
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const rawData = XLSX.utils.sheet_to_json(sheet);
// 현재 DB에 존재하는 모든 asset_core ID 조회
const [existingRows] = await connection.query('SELECT id FROM asset_core');
const existingIds = new Set(existingRows.map(r => r.id));
const failures = [];
for (let i = 0; i < rawData.length; i++) {
const row = rawData[i];
const assetId = `PC_20260615_${String(i + 1).padStart(4, '0')}`;
// DB에 해당 ID가 없는 경우 = 실패(충돌 등의 이유로 입력되지 않음) 또는 스킵된 데이터
// 하지만 이전 로그에서 'Duplicate entry'로 에러가 났던 항목들을 찾는 것이 목적
// 로직상 ID 생성 규칙에 따라 해당 ID가 DB에 없으면 입력에 실패한 행임
if (!existingIds.has(assetId)) {
failures.push({
excel_row: i + 2,
generated_id: assetId,
...row
});
}
}
if (failures.length > 0) {
const newWb = XLSX.utils.book_new();
const newWs = XLSX.utils.json_to_sheet(failures);
XLSX.utils.book_append_sheet(newWb, newWs, 'Failures');
const fileName = 'asset_pc_failures_20260615.xlsx';
XLSX.writeFile(newWb, fileName);
console.log(`✅ 추출 완료: ${failures.length}건의 실패 데이터를 ${fileName}에 저장했습니다.`);
} else {
console.log('입력되지 않은 데이터가 없습니다.');
}
await connection.end();
}
extractFailures().catch(console.error);

View File

@@ -1,29 +0,0 @@
const mysql = require('mysql2/promise');
require('dotenv').config();
async function findPotentialPublic() {
const connection = await mysql.createConnection({
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')
});
console.log('--- Searching for rows with no emp_no or "공용" in user_current ---');
// 사번이 'undefined', 'null', 빈값, 또는 사용자명에 '공용'이 들어간 데이터
const [rows] = await connection.query(`
SELECT id, user_current, emp_no
FROM asset_core
WHERE id LIKE "PC_20260615_%"
AND (emp_no IS NULL OR emp_no = '' OR emp_no = 'undefined' OR user_current LIKE '%공용%')
`);
console.log('Count:', rows.length);
if (rows.length > 0) console.table(rows);
await connection.end();
}
findPotentialPublic().catch(console.error);

View File

@@ -1,47 +0,0 @@
const mysql = require('mysql2/promise');
require('dotenv').config();
async function fixAssetTypes() {
const connection = await mysql.createConnection({
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')
});
console.log('🚀 [데이터 정상화] 사번 기준 자산 유형 재설정 시작...');
// 1. 사번이 있는 모든 신규 자산을 '개인PC'로 강제 전환
const [personalResult] = await connection.query(`
UPDATE asset_core
SET asset_type = '개인PC'
WHERE id LIKE "PC_20260615_%"
AND emp_no IS NOT NULL
AND emp_no != ''
`);
console.log(`✅ 개인PC 정상화 완료: ${personalResult.affectedRows}건 (사번 존재 항목)`);
// 2. 사번이 없는 모든 신규 자산을 '공용PC'로 강제 전환
const [publicResult] = await connection.query(`
UPDATE asset_core
SET asset_type = '공용PC', user_current = '공용'
WHERE id LIKE "PC_20260615_%"
AND (emp_no IS NULL OR emp_no = '')
`);
console.log(`✅ 공용PC 정상화 완료: ${publicResult.affectedRows}건 (사번 부재 항목)`);
// 3. 최종 결과 확인
const [rows] = await connection.query(`
SELECT asset_type, COUNT(*) as count
FROM asset_core
WHERE id LIKE "PC_20260615_%"
GROUP BY asset_type
`);
console.log('\n📊 최종 자산 유형 분포:');
console.table(rows);
await connection.end();
}
fixAssetTypes().catch(console.error);

View File

@@ -1,118 +0,0 @@
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();

View File

@@ -1,128 +0,0 @@
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

@@ -1,88 +0,0 @@
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();

View File

@@ -1,122 +0,0 @@
const XLSX = require('xlsx');
const mysql = require('mysql2/promise');
const dotenv = require('dotenv');
const path = require('path');
dotenv.config({ path: path.join(__dirname, '../.env') });
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function importAssets() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('🚀 [Step 1] 데이터 로드 및 사전 준비...');
// 1. 엑셀 파일 로드
const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx');
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const rawData = XLSX.utils.sheet_to_json(sheet);
// 2. system_users 데이터 맵 생성 (사번 기준 빠른 조회를 위함)
const [userRows] = await connection.query('SELECT emp_no, user_name, dept_name, position, status FROM system_users');
const userMap = new Map();
userRows.forEach(u => userMap.set(String(u.emp_no), u));
// 3. 기존 자산 중복 체크용 맵 생성 (emp_no + asset_type + category)
const [existingAssets] = await connection.query('SELECT emp_no, asset_type, category FROM asset_core');
const existingSet = new Set();
existingAssets.forEach(a => {
existingSet.add(`${a.emp_no}|${a.asset_type}|${a.category}`);
});
console.log(`📊 처리 대상 데이터: ${rawData.length}`);
let skipCount = 0;
let insertCount = 0;
for (let i = 0; i < rawData.length; i++) {
const row = rawData[i];
const empNo = String(row.emp_no);
const assetType = row.asset_type || '개인PC';
const category = row.category || 'PC';
// 중복 체크
if (existingSet.has(`${empNo}|${assetType}|${category}`)) {
skipCount++;
continue;
}
// [Step 2] 데이터 정제
// 1. 사용자 정보 매칭
const matchedUser = userMap.get(empNo);
const userName = matchedUser ? matchedUser.user_name : row.user_current;
const deptName = matchedUser ? matchedUser.dept_name : row.current_dept;
const position = matchedUser ? matchedUser.position : '';
// 2. 날짜 최적화 (purchase_date_1, purchase_date_2 중 최신값)
const d1 = parseInt(row.purchase_date_1) || 0;
const d2 = parseInt(row.purchase_date_2) || 0;
const latestDate = Math.max(d1, d2);
const purchaseDate = latestDate > 0 ? String(latestDate) : '';
// 3. 고유 ID 생성
const assetId = `PC_20260615_${String(i + 1).padStart(4, '0')}`;
const now = new Date().toISOString().replace('T', ' ').substring(0, 19);
try {
// [Step 3] DB 입력
// A. asset_core 입력
await connection.query(
`INSERT INTO asset_core (id, asset_code, category, asset_type, current_role, asset_purpose, service_type,
purchase_corp, purchase_date, memo, manager_primary, current_dept, user_current, emp_no, user_position, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[assetId, assetId, category, assetType, row.current_role, row.asset_purpose, row.service_type,
'', purchaseDate, row.memo || '', '', deptName, userName, empNo, position, now, now]
);
// B. asset_spec 입력
await connection.query(
`INSERT INTO asset_spec (asset_id, model_name, mainboard, cpu, ram, gpu) VALUES (?, ?, ?, ?, ?, ?)`,
[assetId, '', row.mainboard || '', row.cpu || '', row.ram || '', row.gpu || '']
);
// C. asset_volume 입력 (SSD1, SSD2, HDD1~4)
const volumes = [
{ type: 'SSD', cap: row.SDD1, slot: 1 },
{ type: 'SSD', cap: row.SDD2, slot: 2 },
{ type: 'HDD', cap: row.HDD1, slot: 3 },
{ type: 'HDD', cap: row.HDD2, slot: 4 },
{ type: 'HDD', cap: row.HDD3, slot: 5 },
{ type: 'HDD', cap: row.HDD4, slot: 6 }
];
for (const vol of volumes) {
if (vol.cap && vol.cap !== '0' && vol.cap !== 0) {
await connection.query(
`INSERT INTO asset_volume (asset_id, disk_type, capacity, slot_no) VALUES (?, ?, ?, ?)`,
[assetId, vol.type, String(vol.cap), vol.slot]
);
}
}
insertCount++;
existingSet.add(`${empNo}|${assetType}|${category}`); // 실시간 중복 방지 추가
} catch (err) {
console.error(`❌ [${empNo}] 처리 중 오류:`, err.message);
}
}
console.log(`\n✨ 작업 완료!`);
console.log(`- 신규 입력: ${insertCount}`);
console.log(`- 중복 스킵: ${skipCount}`);
await connection.end();
}
importAssets().catch(console.error);

View File

@@ -1,164 +0,0 @@
const XLSX = require('xlsx');
const mysql = require('mysql2/promise');
const dotenv = require('dotenv');
const path = require('path');
dotenv.config({ path: path.join(__dirname, '../.env') });
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
// 용량 정제 함수
function parseCapacity(val) {
if (!val || val === '0' || val === 0) return null;
let str = String(val).toUpperCase();
// 1. 괄호와 그 안의 내용 제거
str = str.replace(/\(.*\)/g, '').trim();
// 2. 숫자와 단위 분리
const numMatch = str.match(/[\d.]+/);
if (!numMatch) return null;
let num = parseFloat(numMatch[0]);
let unit = 'GB'; // 기본 단위
if (str.includes('TB')) {
unit = 'TB';
} else if (str.includes('GB')) {
// 4자리수 GB인 경우 TB로 전환 (지시사항 1번)
if (num >= 1000) {
num = num / 1000;
unit = 'TB';
} else {
unit = 'GB';
}
} else {
// 단위가 명시되지 않은 경우 숫자의 크기로 판단
if (num >= 1000) {
num = num / 1000;
unit = 'TB';
}
}
return {
capacity: parseFloat(num.toFixed(2)),
unit: unit
};
}
async function importAssets() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('🚀 [Step 1] 데이터 로드 및 사전 준비 (정제 로직 강화)...');
const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx');
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const rawData = XLSX.utils.sheet_to_json(sheet);
// system_users 데이터 맵
const [userRows] = await connection.query('SELECT emp_no, user_name, dept_name, position, status FROM system_users');
const userMap = new Map();
userRows.forEach(u => userMap.set(String(u.emp_no), u));
// 기존 자산 중복 체크용 (emp_no + asset_type + category + user_current)
const [existingAssets] = await connection.query('SELECT emp_no, asset_type, category, user_current FROM asset_core');
const existingSet = new Set();
existingAssets.forEach(a => {
existingSet.add(`${a.emp_no || ''}|${a.asset_type}|${a.category}|${a.user_current}`);
});
console.log(`📊 처리 대상 데이터: ${rawData.length}`);
let skipCount = 0;
let insertCount = 0;
let errorCount = 0;
for (let i = 0; i < rawData.length; i++) {
const row = rawData[i];
const empNo = row.emp_no ? String(row.emp_no) : ''; // 사번 없는 행 처리 (지시사항 3번)
const assetType = row.asset_type || '개인PC';
const category = row.category || 'PC';
const userCurrent = row.user_current || '';
// 중복 체크
const dupKey = `${empNo}|${assetType}|${category}|${userCurrent}`;
if (existingSet.has(dupKey)) {
skipCount++;
continue;
}
// [Step 2] 데이터 정제
const matchedUser = empNo ? userMap.get(empNo) : null;
const userName = matchedUser ? matchedUser.user_name : userCurrent;
const deptName = matchedUser ? matchedUser.dept_name : (row.current_dept || '');
const position = matchedUser ? matchedUser.position : '';
const d1 = parseInt(row.purchase_date_1) || 0;
const d2 = parseInt(row.purchase_date_2) || 0;
const purchaseDate = Math.max(d1, d2) > 0 ? String(Math.max(d1, d2)) : '';
const assetId = `PC_20260615_${String(i + 1).padStart(4, '0')}`;
const now = new Date().toISOString().replace('T', ' ').substring(0, 19);
try {
// [Step 3] DB 입력
// A. asset_core
await connection.query(
`INSERT INTO asset_core (id, asset_code, category, asset_type, current_role, asset_purpose, service_type,
purchase_date, memo, current_dept, user_current, emp_no, user_position, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[assetId, assetId, category, assetType, row.current_role || '', row.asset_purpose || '', row.service_type || '',
purchaseDate, row.memo || '', deptName, userName, empNo, position, now, now]
);
// B. asset_spec
await connection.query(
`INSERT INTO asset_spec (asset_id, mainboard, cpu, ram, gpu) VALUES (?, ?, ?, ?, ?)`,
[assetId, row.mainboard || '', row.cpu || '', row.ram || '', row.gpu || '']
);
// C. asset_volume
const volCols = [
{ key: 'SDD1', type: 'SSD', slot: 1 },
{ key: 'SDD2', type: 'SSD', slot: 2 },
{ key: 'HDD1', type: 'HDD', slot: 3 },
{ key: 'HDD2', type: 'HDD', slot: 4 },
{ key: 'HDD3', type: 'HDD', slot: 5 },
{ key: 'HDD4', type: 'HDD', slot: 6 }
];
for (const col of volCols) {
const rawVol = row[col.key];
const parsed = parseCapacity(rawVol);
if (parsed) {
await connection.query(
`INSERT INTO asset_volume (asset_id, disk_type, capacity, unit, slot_no) VALUES (?, ?, ?, ?, ?)`,
[assetId, col.type, parsed.capacity, parsed.unit, col.slot]
);
}
}
insertCount++;
existingSet.add(dupKey);
} catch (err) {
errorCount++;
console.error(`❌ [Row ${i + 2}] ${empNo || 'Public'}: ${err.message}`);
}
}
console.log(`\n✨ 작업 완료!`);
console.log(`- 신규 입력: ${insertCount}`);
console.log(`- 중복 스킵: ${skipCount}`);
console.log(`- 오류 실패: ${errorCount}`);
await connection.end();
}
importAssets().catch(console.error);

View File

@@ -1,61 +0,0 @@
const XLSX = require('xlsx');
const mysql = require('mysql2/promise');
const dotenv = require('dotenv');
const path = require('path');
dotenv.config({ path: path.join(__dirname, '../.env') });
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function importUsers() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('🚀 Excel 데이터 로드 중...');
const workbook = XLSX.readFile('system_User (20260615).xlsx');
const sheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[sheetName];
const data = XLSX.utils.sheet_to_json(sheet);
console.log(`📊 총 ${data.length}개의 데이터를 찾았습니다.`);
// 기존 데이터 삭제 여부 (사용자 요구사항에 따라 결정 가능하지만, 보통 초기화 후 재입입)
// 여기서는 중복 방지를 위해 기존 데이터를 삭제하고 새로 넣는 방식을 취하겠습니다.
console.log('🧹 기존 system_users 데이터 삭제 중...');
await connection.query('DELETE FROM system_users');
console.log('📥 데이터 삽입 중...');
let successCount = 0;
for (let i = 0; i < data.length; i++) {
const row = data[i];
const { emp_no, user_name, dept_name, position, status } = row;
// ID 생성 (USR_ + 인덱스 001 형식)
const id = `USR_${String(i + 1).padStart(3, '0')}`;
const createdAt = new Date().toISOString().replace('T', ' ').substring(0, 19);
try {
await connection.query(
'INSERT INTO system_users (id, emp_no, user_name, dept_name, position, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[id, String(emp_no), user_name, dept_name, position, status, createdAt]
);
successCount++;
} catch (err) {
console.error(`❌ 삽입 실패 (Row ${i + 2}):`, err.message);
}
}
console.log(`✅ 완료: ${successCount}개의 사용자가 성공적으로 등록되었습니다.`);
await connection.end();
}
importUsers().catch(err => {
console.error('❌ 작업 중 오류 발생:', err);
process.exit(1);
});

View File

@@ -1,7 +0,0 @@
const XLSX = require('xlsx');
const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx');
const sheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[sheetName];
const data = XLSX.utils.sheet_to_json(sheet, { header: 1 });
console.log('Headers:', JSON.stringify(data[0], null, 2));
console.log('Sample Row 1:', JSON.stringify(data[1], null, 2));

View File

@@ -1,6 +0,0 @@
const XLSX = require('xlsx');
const workbook = XLSX.readFile('system_User (20260615).xlsx');
const sheetName = workbook.SheetNames[0];
const sheet = workbook.Sheets[sheetName];
const data = XLSX.utils.sheet_to_json(sheet, { header: 1 });
console.log(JSON.stringify(data.slice(0, 5), null, 2));

View File

@@ -1,18 +0,0 @@
const mysql = require('mysql2/promise');
require('dotenv').config();
async function rawCheck() {
const connection = await mysql.createConnection({
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 [rows] = await connection.query('SELECT user_current, emp_no FROM asset_core WHERE id LIKE "PC_20260615_%" LIMIT 10');
console.log(rows);
await connection.end();
}
rawCheck().catch(console.error);

View File

@@ -1,85 +0,0 @@
const mysql = require('mysql2/promise');
require('dotenv').config();
async function rebuildAssetCodes() {
const connection = await mysql.createConnection({
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')
});
console.log('🚀 [Step 1] 신규 자산 구매일 업데이트 (YYYY-12-01)...');
// 1. 오늘 입력한 자산들 조회
const [rows] = await connection.query(
'SELECT id, purchase_date FROM asset_core WHERE id LIKE "PC_20260615_%"'
);
console.log(`대상 자산: ${rows.length}`);
// 2. 구매일자 업데이트 (연도만 있는 경우 -12-01 추가)
for (const row of rows) {
if (row.purchase_date && row.purchase_date.length === 4) {
const newDate = `${row.purchase_date}-12-01`;
await connection.query(
'UPDATE asset_core SET purchase_date = ? WHERE id = ?',
[newDate, row.id]
);
}
}
console.log('✅ 구매일 업데이트 완료.');
console.log('\n🚀 [Step 2] 자산번호(asset_code) 재매핑 시작...');
// 3. 연도별로 그룹화하여 자산번호 부여
// 연도 목록 추출
const [yearRows] = await connection.query(
'SELECT DISTINCT LEFT(purchase_date, 4) as year FROM asset_core WHERE id LIKE "PC_20260615_%" ORDER BY year'
);
for (const yRow of yearRows) {
const year = yRow.year;
const yearMonth = `${year}12`;
const pattern = `PC-${yearMonth}-%`;
console.log(`--- [${year}년] 처리 중 ---`);
// 해당 연도/월의 기존 최대 순번 조회
const [maxRows] = await connection.query(
'SELECT asset_code FROM asset_core WHERE asset_code LIKE ? AND id NOT LIKE "PC_20260615_%"',
[pattern]
);
let maxSeq = 0;
maxRows.forEach(r => {
const parts = r.asset_code.split('-');
const seq = parseInt(parts[2]);
if (seq > maxSeq) maxSeq = seq;
});
console.log(`기존 최대 순번: ${maxSeq}`);
// 해당 연도 자산들 순차적으로 번호 부여
const [assetsOfYear] = await connection.query(
'SELECT id FROM asset_core WHERE id LIKE "PC_20260615_%" AND purchase_date LIKE ? ORDER BY id',
[`${year}-12%`]
);
let currentSeq = maxSeq + 1;
for (const asset of assetsOfYear) {
const newCode = `PC-${yearMonth}-${String(currentSeq).padStart(4, '0')}`;
await connection.query(
'UPDATE asset_core SET asset_code = ? WHERE id = ?',
[newCode, asset.id]
);
currentSeq++;
}
console.log(`신규 부여 완료: ${assetsOfYear.length}건 (순번 ${maxSeq + 1} ~ ${currentSeq - 1})`);
}
console.log('\n✨ 모든 작업이 완료되었습니다.');
await connection.end();
}
rebuildAssetCodes().catch(console.error);

View File

@@ -1,85 +0,0 @@
const XLSX = require('xlsx');
const mysql = require('mysql2/promise');
require('dotenv').config();
async function reexamineData() {
const connection = await mysql.createConnection({
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')
});
console.log('🧐 [전수 조사] 엑셀 vs DB 데이터 비교 분석...');
// 1. 엑셀 데이터 로드
const workbook = XLSX.readFile('asset_pc (2026.06.15).xlsx');
const sheet = workbook.Sheets[workbook.SheetNames[0]];
const excelRows = XLSX.utils.sheet_to_json(sheet);
// 2. DB 데이터 로드
const [dbRows] = await connection.query(`
SELECT id, asset_code, asset_type, user_current, emp_no, current_dept
FROM asset_core
WHERE id LIKE "PC_20260615_%"
`);
const dbMap = new Map();
dbRows.forEach(r => dbMap.set(r.id, r));
const report = {
total: excelRows.length,
publicInExcelWithEmpNo: [], // 엑셀은 공용PC인데 사번이 있는 경우
personalInExcelNoEmpNo: [], // 엑셀은 개인PC인데 사번이 없는 경우
typeMismatch: [], // 엑셀과 DB의 asset_type이 다른 경우
userMismatch: [] // 사용자명이 크게 다른 경우
};
for (let i = 0; i < excelRows.length; i++) {
const ex = excelRows[i];
const id = `PC_20260615_${String(i + 1).padStart(4, '0')}`;
const db = dbMap.get(id);
if (!db) continue;
const exType = ex.asset_type || '개인PC';
const exEmpNo = ex.emp_no ? String(ex.emp_no) : null;
const exUser = ex.user_current || '';
// A. 공용PC인데 사번이 있는 경우 (가장 큰 혼란 포인트)
if (exType === '공용PC' && exEmpNo) {
report.publicInExcelWithEmpNo.push({ id, exUser, exEmpNo, exDept: ex.current_dept });
}
// B. 개인PC인데 사번이 없는 경우
if (exType === '개인PC' && !exEmpNo) {
report.personalInExcelNoEmpNo.push({ id, exUser, exDept: ex.current_dept });
}
// C. DB와의 타입 불일치 (현재 DB 상태 체크)
if (db.asset_type !== exType) {
report.typeMismatch.push({ id, exType, dbType: db.asset_type, user: db.user_current });
}
}
console.log('\n================================================');
console.log(`📊 전수 조사 요약 (총 ${report.total}건)`);
console.log(`1. 엑셀은 '공용PC'이나 '사번'이 있는 항목: ${report.publicInExcelWithEmpNo.length}`);
console.log(`2. 엑셀은 '개인PC'이나 '사번'이 없는 항목: ${report.personalInExcelNoEmpNo.length}`);
console.log(`3. 현재 DB와 엑셀의 '자산유형' 불일치: ${report.typeMismatch.length}`);
console.log('================================================\n');
if (report.publicInExcelWithEmpNo.length > 0) {
console.log('⚠️ [그룹 1] 공용PC인데 실사용자/관리자가 지정된 사례 (샘플 15건):');
console.table(report.publicInExcelWithEmpNo.slice(0, 15));
}
if (report.personalInExcelNoEmpNo.length > 0) {
console.log('\n⚠ [그룹 2] 개인PC인데 사번 정보가 누락된 사례 (샘플 15건):');
console.table(report.personalInExcelNoEmpNo.slice(0, 15));
}
await connection.end();
}
reexamineData().catch(console.error);

View File

@@ -1,92 +0,0 @@
const XLSX = require('xlsx');
const mysql = require('mysql2/promise');
const dotenv = require('dotenv');
const path = require('path');
dotenv.config({ path: path.join(__dirname, '../.env') });
const { DB_HOST, DB_USER, DB_PASS, DB_NAME, DB_PORT } = process.env;
async function restoreAndMerge() {
const connection = await mysql.createConnection({
host: DB_HOST,
user: DB_USER,
password: DB_PASS,
database: DB_NAME,
port: parseInt(DB_PORT || '3306')
});
console.log('🔄 데이터 복구 및 병합 시작...');
// 1. 백업 파일에서 기존 데이터(212건) 로드
const workbookBackup = XLSX.readFile('backupDB_20260602.xlsx');
const oldUsers = XLSX.utils.sheet_to_json(workbookBackup.Sheets['system_users']);
// 2. 신규 파일에서 데이터(987건) 로드
const workbookNew = XLSX.readFile('system_User (20260615).xlsx');
const newUsers = XLSX.utils.sheet_to_json(workbookNew.Sheets[workbookNew.SheetNames[0]]);
console.log(`기본 백업 데이터: ${oldUsers.length}`);
console.log(`신규 추가 데이터: ${newUsers.length}`);
// 테이블 비우기 (실수를 바로잡기 위해 다시 시작)
await connection.query('DELETE FROM system_users');
const insertedEmpNos = new Set();
let restoreCount = 0;
let addCount = 0;
// 3. 기존 데이터 복구 (ID 보존 시도)
for (const user of oldUsers) {
const { id, emp_no, user_name, dept_name, position, status, created_at } = user;
// 엑셀 날짜 처리 (숫자로 되어 있을 경우)
let finalCreatedAt = created_at;
if (typeof created_at === 'number') {
const date = new Date((created_at - 25569) * 86400 * 1000);
finalCreatedAt = date.toISOString().replace('T', ' ').substring(0, 19);
}
try {
await connection.query(
'INSERT INTO system_users (id, emp_no, user_name, dept_name, position, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[id, String(emp_no), user_name, dept_name, position, status, finalCreatedAt]
);
insertedEmpNos.add(String(emp_no));
restoreCount++;
} catch (err) {
console.error(`❌ 복구 실패 (emp_no: ${emp_no}):`, err.message);
}
}
// 4. 신규 데이터 추가 (중복 제외)
for (let i = 0; i < newUsers.length; i++) {
const user = newUsers[i];
const { emp_no, user_name, dept_name, position, status } = user;
const strEmpNo = String(emp_no);
if (insertedEmpNos.has(strEmpNo)) {
continue; // 이미 복구된 데이터는 스킵
}
// 신규 데이터용 ID 생성 (기존 ID와 겹치지 않게 'NEW_' 접두어 또는 시퀀스 사용)
// 여기서는 단순히 시퀀스로 처리 (최대 ID 확인 후 +1 하는 방식이 좋으나 여기선 간단히)
const id = `USR_N_${String(i + 1).padStart(4, '0')}`;
const createdAt = new Date().toISOString().replace('T', ' ').substring(0, 19);
try {
await connection.query(
'INSERT INTO system_users (id, emp_no, user_name, dept_name, position, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
[id, strEmpNo, user_name, dept_name, position, status, createdAt]
);
addCount++;
} catch (err) {
console.error(`❌ 추가 실패 (emp_no: ${emp_no}):`, err.message);
}
}
console.log(`✅ 복구 완료: 기존 ${restoreCount}건 복구, 신규 ${addCount}건 추가 (총 ${restoreCount + addCount}건)`);
await connection.end();
}
restoreAndMerge().catch(console.error);

View File

@@ -1,32 +0,0 @@
const mysql = require('mysql2/promise');
require('dotenv').config();
async function updateDepartments() {
const connection = await mysql.createConnection({
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')
});
console.log("🚀 부서명 '삼안' 통합 업데이트 시작...");
const [result] = await connection.query(`
UPDATE asset_core
SET current_dept = '삼안'
WHERE current_dept NOT IN ('총괄기획실', '기술개발센터', '현타', '장헌', '한맥', 'PTC', '', '삼안')
AND current_dept IS NOT NULL
`);
console.log(`✅ 업데이트 완료: ${result.affectedRows}건의 부서명이 '삼안'으로 변경되었습니다.`);
// 최종 확인용 카운트
const [rows] = await connection.query('SELECT current_dept, COUNT(*) as count FROM asset_core GROUP BY current_dept');
console.log('\n📊 최종 부서 분포:');
console.table(rows);
await connection.end();
}
updateDepartments().catch(console.error);

125
scripts/backup.sh Normal file
View File

@@ -0,0 +1,125 @@
#!/usr/bin/env sh
set -eu
COMMAND="${1:-help}"
ENV_FILE="${ENV_FILE:-.env}"
BACKUP_ROOT="${BACKUP_ROOT:-backups}"
RETENTION_DAYS="${RETENTION_DAYS:-14}"
TIMESTAMP="${BACKUP_TIMESTAMP:-$(date +%Y%m%d_%H%M%S)}"
log() {
printf '[backup] %s\n' "$*"
}
fail() {
printf '[backup] %s\n' "$*" >&2
exit 1
}
require_command() {
command -v "$1" >/dev/null 2>&1 || fail "Required command not found: $1"
}
has_command() {
command -v "$1" >/dev/null 2>&1
}
load_env() {
[ -f "$ENV_FILE" ] || fail "Env file not found: $ENV_FILE"
case "$ENV_FILE" in
*/*) env_path="$ENV_FILE" ;;
*) env_path="./$ENV_FILE" ;;
esac
set -a
# shellcheck disable=SC1090
. "$env_path"
set +a
: "${DB_HOST:?DB_HOST is required in $ENV_FILE}"
: "${DB_PORT:=3306}"
: "${DB_USER:?DB_USER is required in $ENV_FILE}"
: "${DB_PASS:?DB_PASS is required in $ENV_FILE}"
: "${DB_NAME:?DB_NAME is required in $ENV_FILE}"
}
db_dump() {
require_command gzip
load_env
mkdir -p "$BACKUP_ROOT/db"
output_path="$BACKUP_ROOT/db/${DB_NAME}_${TIMESTAMP}.sql.gz"
log "Creating DB dump: $output_path"
if has_command mysqldump; then
MYSQL_PWD="$DB_PASS" mysqldump \
--host="$DB_HOST" \
--port="$DB_PORT" \
--user="$DB_USER" \
--single-transaction \
--quick \
--routines \
--triggers \
"$DB_NAME" | gzip > "$output_path"
elif has_command docker; then
docker exec itam-backend sh -lc "MYSQL_PWD=\"$DB_PASS\" exec mysqldump --host=\"$DB_HOST\" --port=\"$DB_PORT\" --user=\"$DB_USER\" --single-transaction --quick --routines --triggers \"$DB_NAME\"" | gzip > "$output_path"
else
fail "Required command not found: mysqldump (and docker fallback unavailable)"
fi
log "DB dump completed: $output_path"
}
files_backup() {
require_command tar
mkdir -p "$BACKUP_ROOT/files"
archive_path="$BACKUP_ROOT/files/runtime_${TIMESTAMP}.tar.gz"
set --
[ -f "$ENV_FILE" ] && set -- "$@" "$ENV_FILE"
[ -d "uploads" ] && set -- "$@" "uploads"
[ -f "map_config.json" ] && set -- "$@" "map_config.json"
[ "$#" -gt 0 ] || fail "No runtime files found to archive"
log "Creating runtime archive: $archive_path"
tar -czf "$archive_path" "$@"
log "Runtime archive completed: $archive_path"
}
cleanup_backups() {
require_command find
[ -d "$BACKUP_ROOT" ] || {
log "Backup root does not exist, skipping cleanup: $BACKUP_ROOT"
return 0
}
log "Deleting backup files older than ${RETENTION_DAYS} days from $BACKUP_ROOT"
find "$BACKUP_ROOT" -type f -mtime "+$RETENTION_DAYS" -print -delete
}
case "$COMMAND" in
db)
db_dump
;;
files)
files_backup
;;
full)
db_dump
files_backup
;;
cleanup)
cleanup_backups
;;
help|--help|-h)
log "Commands: db | files | full | cleanup"
;;
*)
fail "Unknown command: $COMMAND"
;;
esac

546
server.js
View File

@@ -4,7 +4,22 @@ import cors from 'cors';
import dotenv from 'dotenv';
import fs from 'fs';
dotenv.config({ override: true });
dotenv.config();
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();
app.use(cors());
@@ -18,11 +33,11 @@ if (!fs.existsSync('uploads')) {
// MySQL Pool Configuration
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'),
host: dbConfig.host,
user: dbConfig.user,
password: dbConfig.password,
database: dbConfig.database,
port: dbConfig.port,
waitForConnections: true,
connectionLimit: 10,
queueLimit: 0
@@ -48,7 +63,15 @@ const pool = mysql.createPool({
`);
console.log('✅ job_spec_standards table verification completed.');
} catch (err) {
console.error('❌ Failed to verify/create job_spec_standards table:', 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();
}
@@ -56,7 +79,15 @@ const pool = mysql.createPool({
// Error Handler
const handleError = (res, err, label) => {
console.error(`❌ [${label}] Error:`, err);
console.error(`❌ [${label}] Error:`, {
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 });
};
@@ -82,6 +113,30 @@ const ASSET_TABLES = [
'asset_core'
];
// --- Helper Functions for Maps ---
function getCleanMapKey(path) {
let clean = path.replace('img/location_photo/', '').replace('.png', '');
clean = clean.replace('서관', 'W').replace('동관', 'E');
clean = clean.replace('한맥빌딩/MDF실/MDF_', 'HAN-MDF-');
clean = clean.replace('기술개발센터/서버실/서버실_', 'DEV-SVR-');
clean = clean.replace(/\//g, '-');
return clean;
}
function getLocationName(path) {
if (path.includes('IDC')) return 'IDC';
if (path.includes('한맥빌딩')) return '한맥빌딩';
if (path.includes('기술개발센터')) return '기술개발센터';
return '기타';
}
function getLocationDetail(path, idx) {
let clean = path.replace('img/location_photo/', '').replace('.png', '');
let parts = clean.split('/');
let lastPart = parts[parts.length - 1];
return `${lastPart} 구역 자리 #${idx + 1}`;
}
// --- API Endpoints ---
// 1. Generic Batch Save (Dynamic Table Detection)
@@ -143,6 +198,9 @@ app.get('/api/assets/master', async (req, res) => {
s.hw_status, s.model_name, s.mainboard, s.os, s.cpu, s.ram, s.gpu,
s.monitoring, s.price, s.monitor_inch, s.serial_num,
l.location, l.location_detail, l.location_photo, l.loc_x, l.loc_y,
(
SELECT EXISTS(SELECT 1 FROM asset_audit_pending WHERE asset_code = c.asset_code AND status = 'APPROVED')
) AS is_audit_approved,
(
SELECT JSON_ARRAYAGG(JSON_OBJECT('type', net_type, 'name', net_name, 'val1', net_value1, 'val2', net_value2))
FROM asset_remote WHERE asset_id = c.id AND is_active = 1
@@ -162,7 +220,7 @@ app.get('/api/assets/master', async (req, res) => {
const catMap = {
'PC': 'pc', '서버': 'server', '저장매체': 'storage', '네트워크': 'network',
'업무지원장비': 'equipment', '사무가구': 'officeSupplies', '공간정보장비': 'survey',
'업무지원장비': 'equipment', '시설자산': 'officeSupplies', '공간정보장비': 'survey',
'내빈/외빈': 'vip', 'PC부품': 'pcParts'
};
@@ -203,9 +261,34 @@ app.post('/api/asset/:category/save', async (req, res) => {
connection = await pool.getConnection();
await connection.beginTransaction();
// 3.0.0 CPU, GPU, RAM 부품 마스터 유효성 검사
const partsToCheck = [
{ value: asset.cpu, category: 'CPU', label: 'CPU' },
{ value: asset.gpu, category: 'GPU', label: 'GPU' },
{ value: asset.ram, category: 'RAM', label: 'RAM' }
];
for (const part of partsToCheck) {
const val = String(part.value || '').trim();
if (val) {
const [rows] = await connection.query(
'SELECT id FROM hardware_components_master WHERE UPPER(category) = ? AND LOWER(TRIM(component_name)) = ?',
[part.category, val.toLowerCase()]
);
if (rows.length === 0) {
await connection.rollback();
return res.status(400).json({
success: false,
message: `입력하신 ${part.label} "${val}"은(는) 부품 마스터에 존재하지 않는 규격입니다.`
});
}
}
}
// 3.0 History Tracking & Auto Field Update
const [oldCoreRows] = await connection.query('SELECT * FROM asset_core WHERE id = ?', [asset.id]);
const [oldSpecRows] = await connection.query('SELECT * FROM asset_spec WHERE asset_id = ?', [asset.id]);
const [oldRemoteRows] = await connection.query('SELECT * FROM asset_remote WHERE asset_id = ? AND is_active = 1', [asset.id]);
const oldCore = oldCoreRows[0] || {};
const oldSpec = oldSpecRows[0] || {};
@@ -268,6 +351,85 @@ app.post('/api/asset/:category/save', async (req, res) => {
});
}
// 3.0.4 메모, 용도, 유형 변동 감지
const oldMemo = String(oldCore.memo || '').trim();
const newMemo = String(asset.memo || '').trim();
if (newMemo !== '' && oldMemo !== newMemo) {
historyLogs.push({
event_type: 'MEMO_CHANGE',
details: `[메모 변경] ${oldMemo || '(없음)'} -> ${newMemo}`
});
}
const oldPurpose = String(oldCore.asset_purpose || '').trim();
const newPurpose = String(asset.asset_purpose || '').trim();
if (newPurpose !== '' && oldPurpose !== newPurpose) {
historyLogs.push({
event_type: 'PURPOSE_CHANGE',
details: `[용도 변경] ${oldPurpose || '(없음)'} -> ${newPurpose}`
});
}
const oldType = String(oldCore.asset_type || '').trim();
const newType = String(asset.asset_type || '').trim();
if (newType !== '' && oldType !== newType) {
historyLogs.push({
event_type: 'TYPE_CHANGE',
details: `[유형 변경] ${oldType || '(없음)'} -> ${newType}`
});
}
// 3.0.5 접속정보 변동 감지
const formatRemote = (r) => {
const type = r.net_type || r.type || '';
const name = r.net_name || r.name || '';
const val1 = r.net_value1 || r.val1 || '';
const val2 = r.net_value2 || r.val2 || '';
return `${type}:${name}:${val1}:${val2}`;
};
const oldRemotesSummary = oldRemoteRows.map(formatRemote).sort().join(' | ');
let newNets = [];
if (asset.remotes) {
try {
newNets = typeof asset.remotes === 'string' ? JSON.parse(asset.remotes) : asset.remotes;
} catch(e) { newNets = []; }
} else if (asset.ip_address || asset.mac_address || asset.remote_tool) {
if (asset.ip_address || asset.mac_address) {
newNets.push({ type: 'IP', name: '기본망', val1: asset.ip_address, val2: asset.mac_address });
}
if (asset.remote_tool || asset.remote_id || asset.remote_pw) {
newNets.push({ type: 'REMOTE', name: asset.remote_tool, val1: asset.remote_id, val2: asset.remote_pw });
}
}
const newRemotesSummary = newNets.map(formatRemote).sort().join(' | ');
if (newRemotesSummary !== '' && oldRemotesSummary !== newRemotesSummary) {
const formatDisplay = (summary) => {
if (!summary) return '(없음)';
return summary.split(' | ').map(item => {
const [type, name, val1, val2] = item.split(':');
if (type === 'IP') {
return `[IP] ${name}: ${val1} (MAC: ${val2 || '없음'})`;
} else {
let id = '', pw = '';
try {
const parsed = JSON.parse(val2);
id = parsed.id || '';
pw = parsed.pw || '';
} catch(e) { id = val1; pw = val2; }
return `[원격] ${name}: ID=${id || '없음'}, PW=${pw ? '***' : '없음'}`;
}
}).join(', ');
};
historyLogs.push({
event_type: 'REMOTE_CHANGE',
details: `[접속정보 변경] ${formatDisplay(oldRemotesSummary)} -> ${formatDisplay(newRemotesSummary)}`
});
}
// 로그 일괄 삽입
for (const log of historyLogs) {
await connection.query(
@@ -515,13 +677,121 @@ app.get('/api/generate-asset-code', async (req, res) => {
} catch (err) { handleError(res, err, 'GENERATE CODE'); }
});
// 6. Map Config API
app.get('/api/maps', (req, res) => {
// 6. Map Config API (Adopt database-driven locations from origin/QR_setting)
app.get('/api/maps', async (req, res) => {
try {
if (!fs.existsSync('map_config.json')) return res.json({});
const data = fs.readFileSync('map_config.json', 'utf8');
res.json(JSON.parse(data || '{}'));
} catch (err) { handleError(res, err, 'GET MAPS'); }
const query = `
SELECT
pl.location_code,
pl.location_name,
pl.location_detail,
pl.map_image,
pl.map_x,
pl.map_y,
pl.map_w,
pl.map_h,
al.asset_id
FROM physical_locations pl
LEFT JOIN asset_location al ON al.physical_location_code = pl.location_code AND al.is_active = 1
`;
const [rows] = await pool.query(query);
const mapConfig = {};
rows.forEach(row => {
const mapPath = row.map_image;
if (!mapConfig[mapPath]) {
mapConfig[mapPath] = [];
}
mapConfig[mapPath].push({
x: parseFloat(row.map_x).toFixed(2),
y: parseFloat(row.map_y).toFixed(2),
w: parseFloat(row.map_w).toFixed(2),
h: parseFloat(row.map_h).toFixed(2),
asset_id: row.asset_id
});
});
res.json(mapConfig);
} catch (err) {
handleError(res, err, 'GET MAPS');
}
});
app.post('/api/maps/save', async (req, res) => {
let connection;
try {
const { path, boxes } = req.body;
if (!path) return res.status(400).json({ error: 'Path is required' });
if (!Array.isArray(boxes)) return res.status(400).json({ error: 'Boxes must be an array' });
connection = await pool.getConnection();
await connection.beginTransaction();
const cleanKey = getCleanMapKey(path);
const locName = getLocationName(path);
// 1. Get old location codes for this map
const [oldLocs] = await connection.query(
'SELECT location_code FROM physical_locations WHERE map_image = ?',
[path]
);
const oldLocCodes = oldLocs.map(r => r.location_code);
// 2. Deactivate and clear foreign key references in asset_location to these old location codes
if (oldLocCodes.length > 0) {
await connection.query(
'UPDATE asset_location SET is_active = 0, deactivated_at = NOW(), physical_location_code = NULL WHERE physical_location_code IN (?)',
[oldLocCodes]
);
}
// 3. Delete old physical locations for this map
await connection.query(
'DELETE FROM physical_locations WHERE map_image = ?',
[path]
);
// 4. Insert new physical locations and setup asset_location mappings
for (let i = 0; i < boxes.length; i++) {
const box = boxes[i];
const padIdx = String(i + 1).padStart(3, '0');
const locCode = `LOC-${cleanKey}-${padIdx}`;
const locDetail = getLocationDetail(path, i);
// Insert physical location
await connection.query(`
INSERT INTO physical_locations
(location_code, location_name, location_detail, map_image, map_x, map_y, map_w, map_h)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`, [locCode, locName, locDetail, path, box.x, box.y, box.w, box.h]);
// If asset_id is mapped, update asset_location
if (box.asset_id) {
// Deactivate old active locations for this asset
await connection.query(
'UPDATE asset_location SET is_active = 0, deactivated_at = NOW() WHERE asset_id = ? AND is_active = 1',
[box.asset_id]
);
// Insert new active location mapping
const pathPartsForMap = path.split('/');
const stdDetailForMap = pathPartsForMap[pathPartsForMap.length - 2] || locDetail;
await connection.query(`
INSERT INTO asset_location
(asset_id, location, location_detail, location_photo, loc_x, loc_y, physical_location_code, is_active)
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
`, [box.asset_id, locName, stdDetailForMap, path, box.x, box.y, locCode]);
}
}
await connection.commit();
res.json({ success: true, message: 'Map and Database synced successfully' });
} catch (err) {
if (connection) await connection.rollback();
handleError(res, err, 'SAVE MAPS SYNC');
} finally {
if (connection) connection.release();
}
});
// 6.5. Get Hardware Components Master List
@@ -675,38 +945,212 @@ app.delete('/api/system-users/:id', async (req, res) => {
}
});
app.post('/api/maps/save', async (req, res) => {
// ==========================================
// 8. QR Asset Audit & Scan APIs (From origin/QR_setting)
// ==========================================
// GET all physical locations
app.get('/api/physical-locations', async (req, res) => {
try {
const [rows] = await pool.query('SELECT * FROM physical_locations ORDER BY location_code');
res.json(rows);
} catch (err) {
handleError(res, err, 'GET PHYSICAL LOCATIONS');
}
});
// POST register scan (mobile)
app.post('/api/audit/scan', async (req, res) => {
let connection;
try {
const { path, boxes } = req.body;
if (!path) return res.status(400).json({ error: 'Path is required' });
// 1. Get old config to track movements
let oldConfig = {};
if (fs.existsSync('map_config.json')) {
oldConfig = JSON.parse(fs.readFileSync('map_config.json', 'utf8') || '{}');
const { asset_code, physical_location_code } = req.body;
if (!asset_code || !physical_location_code) {
return res.status(400).json({ error: 'asset_code and physical_location_code are required' });
}
const oldBoxes = oldConfig[path] || [];
// 2. Save new config to file
oldConfig[path] = boxes;
fs.writeFileSync('map_config.json', JSON.stringify(oldConfig, null, 2));
// 3. Sync Database Assets (asset_location table)
connection = await pool.getConnection();
for (const box of boxes) {
if (box.asset_id) {
console.log(`Syncing asset ${box.asset_id} to new position: [${box.x}, ${box.y}]`);
// Verify if asset exists
const [assets] = await connection.query('SELECT id FROM asset_core WHERE asset_code = ?', [asset_code]);
if (assets.length === 0) {
return res.status(404).json({ error: `Asset with code ${asset_code} not found` });
}
// Insert pending audit record
const [result] = await connection.query(
'INSERT INTO asset_audit_pending (asset_code, physical_location_code, status) VALUES (?, ?, ?)',
[asset_code, physical_location_code, 'PENDING']
);
res.json({ success: true, pending_id: result.insertId });
} catch (err) {
handleError(res, err, 'REGISTER SCAN');
} finally {
if (connection) connection.release();
}
});
// GET pending audits list (admin)
app.get('/api/audit/pending', async (req, res) => {
try {
const [rows] = await pool.query(`
SELECT
ap.*,
c.id AS asset_id,
c.asset_purpose,
c.asset_type,
pl.location_name,
pl.location_detail,
pl.map_image,
l.location AS old_location,
l.location_detail AS old_location_detail
FROM asset_audit_pending ap
JOIN asset_core c ON c.asset_code = ap.asset_code
JOIN physical_locations pl ON pl.location_code = ap.physical_location_code
LEFT JOIN asset_location l ON l.asset_id = c.id AND l.is_active = 1
WHERE ap.status = 'PENDING'
ORDER BY ap.scanned_at DESC
`);
res.json(rows);
} catch (err) {
handleError(res, err, 'GET PENDING AUDITS');
}
});
// POST approve audits (admin)
app.post('/api/audit/approve', async (req, res) => {
let connection;
try {
const { pending_ids, processed_by } = req.body;
if (!Array.isArray(pending_ids) || pending_ids.length === 0) {
return res.status(400).json({ error: 'pending_ids must be a non-empty array' });
}
connection = await pool.getConnection();
await connection.beginTransaction();
let mapConfigChanged = false;
let mapConfig = {};
if (fs.existsSync('map_config.json')) {
mapConfig = JSON.parse(fs.readFileSync('map_config.json', 'utf8') || '{}');
}
for (const pendingId of pending_ids) {
// 1. Get pending scan details
const [pendings] = await connection.query(
'SELECT asset_code, physical_location_code FROM asset_audit_pending WHERE id = ? AND status = ?',
[pendingId, 'PENDING']
);
if (pendings.length === 0) continue;
const { asset_code, physical_location_code } = pendings[0];
// 2. Get asset ID
const [assets] = await connection.query('SELECT id FROM asset_core WHERE asset_code = ?', [asset_code]);
if (assets.length === 0) continue;
const assetId = assets[0].id;
// 3. Get physical location details
const [locations] = await connection.query(
'SELECT location_name, location_detail, map_image, map_x, map_y FROM physical_locations WHERE location_code = ?',
[physical_location_code]
);
if (locations.length === 0) continue;
const loc = locations[0];
// 4. Deactivate old active locations for this asset
await connection.query(
'UPDATE asset_location SET loc_x = ?, loc_y = ? WHERE asset_id = ? AND is_active = 1',
[box.x, box.y, box.asset_id]
'UPDATE asset_location SET is_active = 0, deactivated_at = NOW() WHERE asset_id = ? AND is_active = 1',
[assetId]
);
// 5. Insert new active location
const pathPartsForApprove = loc.map_image.split('/');
const stdDetailForApprove = pathPartsForApprove[pathPartsForApprove.length - 2] || loc.location_detail;
await connection.query(`
INSERT INTO asset_location
(asset_id, location, location_detail, location_photo, loc_x, loc_y, physical_location_code, is_active)
VALUES (?, ?, ?, ?, ?, ?, ?, 1)
`, [assetId, loc.location_name, stdDetailForApprove, loc.map_image, loc.map_x, loc.map_y, physical_location_code]);
// 6. Update pending audit status
await connection.query(
'UPDATE asset_audit_pending SET status = ?, processed_at = NOW(), processed_by = ? WHERE id = ?',
['APPROVED', processed_by || 'ADMIN', pendingId]
);
// 7. Sync map_config.json
// Remove asset from any other map coordinates
for (const [mapPath, boxes] of Object.entries(mapConfig)) {
let changed = false;
const newBoxes = boxes.map(b => {
if (b.asset_id === assetId) {
changed = true;
return { ...b, asset_id: null };
}
return b;
});
if (changed) {
mapConfig[mapPath] = newBoxes;
mapConfigChanged = true;
}
}
// Add asset to the new map coordinate box matching map_image, map_x, map_y
if (mapConfig[loc.map_image]) {
const ax = parseFloat(loc.map_x);
const ay = parseFloat(loc.map_y);
const boxes = mapConfig[loc.map_image];
const matchedBox = boxes.find(b => {
const bx = parseFloat(b.x);
const by = parseFloat(b.y);
return Math.abs(bx - ax) < 0.1 && Math.abs(by - ay) < 0.1;
});
if (matchedBox) {
matchedBox.asset_id = assetId;
mapConfigChanged = true;
}
}
}
if (mapConfigChanged) {
fs.writeFileSync('map_config.json', JSON.stringify(mapConfig, null, 2));
}
await connection.commit();
res.json({ success: true, message: 'Audits approved successfully' });
} catch (err) {
if (connection) await connection.rollback();
handleError(res, err, 'APPROVE AUDITS');
} finally {
if (connection) connection.release();
}
});
// POST reject audits (admin)
app.post('/api/audit/reject', async (req, res) => {
let connection;
try {
const { pending_ids, processed_by } = req.body;
if (!Array.isArray(pending_ids) || pending_ids.length === 0) {
return res.status(400).json({ error: 'pending_ids must be a non-empty array' });
}
connection = await pool.getConnection();
await connection.beginTransaction();
for (const pendingId of pending_ids) {
await connection.query(
'UPDATE asset_audit_pending SET status = ?, processed_at = NOW(), processed_by = ? WHERE id = ? AND status = ?',
['REJECTED', processed_by || 'ADMIN', pendingId, 'PENDING']
);
}
}
res.json({ success: true, message: 'Map and Database synced successfully' });
await connection.commit();
res.json({ success: true, message: 'Audits rejected successfully' });
} catch (err) {
handleError(res, err, 'SAVE MAPS SYNC');
if (connection) await connection.rollback();
handleError(res, err, 'REJECT AUDITS');
} finally {
if (connection) connection.release();
}
@@ -736,6 +1180,30 @@ app.post('/api/upload', (req, res) => {
}
});
app.listen(3000, '0.0.0.0', () => {
console.log('📡 ITAM BACKEND SERVER RUNNING ON PORT 3000 (V3 Normalized)');
// Health check endpoint for container orchestration
app.get('/health', async (req, res) => {
try {
const connection = await pool.getConnection();
await connection.query('SELECT 1');
connection.release();
res.status(200).json({ status: 'ok', db: 'connected' });
} catch (err) {
res.status(200).json({ status: 'degraded', db: 'unreachable', error: err.message });
}
});
// Readiness check endpoint (only returns 200 if fully ready)
app.get('/ready', async (req, res) => {
try {
const connection = await pool.getConnection();
await connection.query('SELECT 1');
connection.release();
res.status(200).json({ status: 'ready' });
} catch (err) {
res.status(503).json({ status: 'not_ready', error: err.message });
}
});
app.listen(process.env.PORT || 3000, '0.0.0.0', () => {
console.log(`📡 ITAM BACKEND SERVER RUNNING ON PORT ${process.env.PORT || 3000} (V3 Normalized)`);
});

View File

@@ -11,6 +11,7 @@ import {
} from './ModalUtils';
import { CORP_LIST, LOCATION_DATA, CATEGORY_TYPE_MAP, HW_STATUS_LIST, ORG_LIST, IMAGE_LOCATIONS, TYPE_PREFIX_MAP } from './SharedData';
import { BaseModal } from './BaseModal';
import { QRPrinter } from '../../core/qr_print';
/**
* 하드웨어 자산 상세 모달 (Styled Main Edition)
@@ -30,9 +31,11 @@ class HwAssetModal extends BaseModal {
<div id="hw-asset-modal" class="modal-overlay hidden">
<div class="modal-content wide">
<div class="modal-header">
<div class="header-left">
<h2 id="hw-modal-title" class="modal-title">${this.title}</h2>
<div id="hw-header-identity" class="header-identity"></div>
<div class="header-left" style="display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap;">
<h2 id="hw-modal-title" class="modal-title" style="display: none;">${this.title}</h2>
<div id="hw-header-identity" class="header-identity" style="display: inline-flex; gap: 0.5rem; align-items: center;"></div>
<button id="btn-print-hw-qr" class="btn btn-outline btn-primary hidden" style="padding: 2px 8px; font-size: 11px; height: 22px; margin: 0; line-height: 1; display: inline-flex; align-items: center; justify-content: center; cursor: pointer;">QR 인쇄</button>
<span id="hw-modal-audit-approved-badge" style="display: none; align-items: center; background-color: rgba(16, 185, 129, 0.08); color: #059669; border: 1px solid rgba(16, 185, 129, 0.18); padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: 600; height: 20px; line-height: 1; vertical-align: middle; white-space: nowrap; margin-left: 4px;">승인완료</span>
</div>
<button id="btn-close-hw-modal" class="btn-icon" aria-label="닫기">&times;</button>
</div>
@@ -74,7 +77,7 @@ class HwAssetModal extends BaseModal {
<label>${ASSET_SCHEMA.HW_STATUS.ui}</label>
<select id="hw-hw_status" name="hw_status">${generateOptionsHTML(HW_STATUS_LIST)}</select>
</div>
<div class="form-group">
<div class="form-group service-type-field">
<label>${ASSET_SCHEMA.SERVICE_TYPE.ui}</label>
<select id="hw-service_type" name="service_type">
<option value="외부">외부</option>
@@ -107,9 +110,14 @@ class HwAssetModal extends BaseModal {
<label>${ASSET_SCHEMA.MANAGER_SUB.ui}</label>
<input type="text" id="hw-manager_secondary" name="manager_secondary" />
</div>
<div class="form-group personal-only">
<div class="form-group personal-only relative">
<label>${ASSET_SCHEMA.CURRENT_USER.ui}</label>
<input type="text" id="hw-user_current" name="user_current" />
<input type="text" id="hw-user_current" name="user_current" autocomplete="off" />
<div id="hw-user-current-list" class="autocomplete-list hidden"></div>
</div>
<div class="form-group personal-only">
<label>${ASSET_SCHEMA.EMP_NO.ui}</label>
<input type="text" id="hw-emp_no" name="emp_no" readonly style="background-color: #f1f5f9; cursor: not-allowed;" />
</div>
<div class="form-group personal-only">
<label>${ASSET_SCHEMA.USER_POSITION.ui}</label>
@@ -264,10 +272,26 @@ class HwAssetModal extends BaseModal {
const detailSelect = document.getElementById('hw-location_detail') as HTMLSelectElement;
this.fetchMapConfig();
const qrPrintBtn = document.getElementById('btn-print-hw-qr');
qrPrintBtn?.addEventListener('click', () => {
if (this.currentAsset && this.currentAsset.asset_code) {
QRPrinter.print([{
type: 'asset',
code: this.currentAsset.asset_code,
title: '[ HM IT ASSET ]',
subtitle: this.currentAsset.model_name || this.currentAsset.asset_purpose || this.currentAsset.category || 'IT 자산',
dept: this.currentAsset.current_dept || '-',
user: this.currentAsset.user_current || '-'
}]);
}
});
this.fetchMasterComponents().then(() => {
this.bindAutocomplete('hw-cpu', 'hw-cpu-list', 'CPU');
this.bindAutocomplete('hw-ram', 'hw-ram-list', 'RAM');
this.bindAutocomplete('hw-gpu', 'hw-gpu-list', 'GPU');
this.bindUserAutocomplete();
});
categorySelect.addEventListener('change', () => {
@@ -296,10 +320,11 @@ class HwAssetModal extends BaseModal {
document.getElementById('btn-gen-hw-code')?.addEventListener('click', async () => {
const cat = categorySelect.value;
if (!cat) { alert('구분을 먼저 선택해주세요.'); return; }
const prefix = TYPE_PREFIX_MAP[cat] || 'ETC';
const type = (document.getElementById('hw-asset_type') as HTMLSelectElement)?.value || '';
const prefix = TYPE_PREFIX_MAP[type] || TYPE_PREFIX_MAP[cat] || 'ETC';
const purchaseDate = (document.getElementById('hw-purchase_date') as HTMLInputElement)?.value || '';
try {
const res = await fetch(`http://${location.hostname}:3000/api/generate-asset-code?prefix=${prefix}&purchaseDate=${purchaseDate}`);
const res = await fetch(`/api/generate-asset-code?prefix=${prefix}&purchaseDate=${purchaseDate}`);
const data = await res.json();
if (data.nextCode) setFieldValue('hw-asset_code', data.nextCode);
} catch (err) { console.error('코드 생성 실패:', err); }
@@ -317,7 +342,7 @@ class HwAssetModal extends BaseModal {
const reader = new FileReader();
reader.onload = async () => {
try {
const res = await fetch(`http://${location.hostname}:3000/api/upload`, {
const res = await fetch('/api/upload', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ fileName: file.name, fileData: reader.result })
@@ -326,7 +351,7 @@ class HwAssetModal extends BaseModal {
if (data.success) {
setFieldValue('hw-approval_document', data.filePath);
if (fileLinkContainer) {
fileLinkContainer.innerHTML = `<a href="http://${location.hostname}:3000${data.filePath}" target="_blank" class="btn btn-outline btn-sm">[파일 보기]</a>`;
fileLinkContainer.innerHTML = `<a href="${data.filePath}" target="_blank" class="btn btn-outline btn-sm">[파일 보기]</a>`;
}
}
} catch (err) { console.error('파일 업로드 실패:', err); alert('파일 업로드 중 오류가 발생했습니다.'); }
@@ -382,10 +407,11 @@ class HwAssetModal extends BaseModal {
if (!assetCode) {
const cat = categorySelect.value;
if (!cat) { alert('구분을 먼저 선택해주세요.'); return; }
const prefix = TYPE_PREFIX_MAP[cat] || 'ETC';
const type = (document.getElementById('hw-asset_type') as HTMLSelectElement)?.value || '';
const prefix = TYPE_PREFIX_MAP[type] || TYPE_PREFIX_MAP[cat] || 'ETC';
const purchaseDate = (document.getElementById('hw-purchase_date') as HTMLInputElement)?.value || '';
try {
const res = await fetch(`http://${location.hostname}:3000/api/generate-asset-code?prefix=${prefix}&purchaseDate=${purchaseDate}`);
const res = await fetch(`/api/generate-asset-code?prefix=${prefix}&purchaseDate=${purchaseDate}`);
const data = await res.json();
if (data.nextCode) {
setFieldValue('hw-asset_code', data.nextCode);
@@ -434,6 +460,27 @@ class HwAssetModal extends BaseModal {
formData.forEach((value, key) => { if (key !== 'id') updated[key] = value; });
updated.location = bldgSelect.value;
// 부품 마스터 기준 정합성 검증 (CPU, GPU, RAM)
const checkFields = [
{ name: 'cpu', label: 'CPU', category: 'CPU' },
{ name: 'gpu', label: 'GPU', category: 'GPU' },
{ name: 'ram', label: 'RAM', category: 'RAM' }
];
for (const field of checkFields) {
const value = String(updated[field.name] || '').trim();
if (value) {
const isExists = this.masterComponents.some(c =>
c.category.toUpperCase() === field.category &&
c.component_name.trim().toLowerCase() === value.toLowerCase()
);
if (!isExists) {
alert(`입력하신 ${field.label} "${value}"은(는) 부품 마스터에 등록되지 않은 규격입니다. 자동완성 목록에서 선택하거나 부품마스터에 먼저 등록해 주세요.`);
return;
}
}
}
if (await saveAsset(this.getCategoryKey(updated), updated)) {
alert(UI_TEXT.MESSAGES.SAVE_SUCCESS);
onSave(); this.close(); closeModals();
@@ -563,6 +610,7 @@ class HwAssetModal extends BaseModal {
setFieldValue('hw-manager_primary', asset.manager_primary || '');
setFieldValue('hw-manager_secondary', asset.manager_secondary || '');
setFieldValue('hw-user_current', asset.user_current || '');
setFieldValue('hw-emp_no', asset.emp_no || '');
setFieldValue('hw-user_position', asset.user_position || '');
setFieldValue('hw-previous_user', asset.previous_user || '');
setFieldValue('hw-model_name', asset.model_name || '');
@@ -621,7 +669,7 @@ class HwAssetModal extends BaseModal {
if (docName) docName.textContent = asset.approval_document ? asset.approval_document.split('/').pop() : '파일 선택...';
const fileLinkContainer = document.getElementById('hw-file-link-container');
if (fileLinkContainer && asset.approval_document) {
fileLinkContainer.innerHTML = `<a href="http://${location.hostname}:3000${asset.approval_document}" target="_blank" class="btn btn-outline btn-sm">[파일 보기]</a>`;
fileLinkContainer.innerHTML = `<a href="${asset.approval_document}" target="_blank" class="btn btn-outline btn-sm">[파일 보기]</a>`;
} else if (fileLinkContainer) {
fileLinkContainer.innerHTML = '';
}
@@ -642,6 +690,19 @@ class HwAssetModal extends BaseModal {
protected onAfterOpen(asset: any, mode: string): void {
const genBtn = document.getElementById('btn-gen-hw-code');
if (genBtn) genBtn.style.display = (mode === 'add') ? 'inline-flex' : 'none';
const qrBtn = document.getElementById('btn-print-hw-qr');
if (qrBtn) {
const hasCode = asset && asset.asset_code && asset.asset_code.trim() !== '';
qrBtn.classList.toggle('hidden', mode !== 'view' || !hasCode);
}
const approvedBadge = document.getElementById('hw-modal-audit-approved-badge');
if (approvedBadge) {
const isApproved = asset && asset.is_audit_approved;
approvedBadge.style.display = (mode === 'view' && isApproved) ? 'inline-flex' : 'none';
}
this.toggleFileUploadUI(mode !== 'view');
this.toggleEditOnlyBtns(mode !== 'view');
this.updateMapButtonVisibility();
@@ -691,8 +752,10 @@ class HwAssetModal extends BaseModal {
const hasSN = !['사무가구', 'PC부품'].includes(category);
const isParts = ['PC부품', '사무가구'].includes(category);
const showRemote = category === '서버' || type.includes('서버');
const showServiceType = category === '서버' || type === '서버PC';
document.querySelectorAll('.remote-section, .remote-field, .monitoring-field').forEach(el => (el as HTMLElement).style.display = showRemote ? '' : 'none');
document.querySelectorAll('.service-type-field').forEach(el => (el as HTMLElement).style.display = showServiceType ? '' : 'none');
document.querySelectorAll('.net-only').forEach(el => (el as HTMLElement).style.display = showNet ? '' : 'none');
document.querySelectorAll('.spec-only').forEach(el => (el as HTMLElement).style.display = hasSpec ? '' : 'none');
document.querySelectorAll('.location-section, .location-field').forEach(el => (el as HTMLElement).style.display = (isInfra || category === '공간정보장비') ? '' : 'none');
@@ -902,6 +965,89 @@ class HwAssetModal extends BaseModal {
overlay.querySelector('#btn-preview-close')?.addEventListener('click', () => overlay.remove());
}
private bindUserAutocomplete() {
const input = document.getElementById('hw-user_current') as HTMLInputElement;
const list = document.getElementById('hw-user-current-list') as HTMLDivElement;
const deptSelect = document.getElementById('hw-current_dept') as HTMLSelectElement;
const positionInput = document.getElementById('hw-user_position') as HTMLInputElement;
const empNoInput = document.getElementById('hw-emp_no') as HTMLInputElement;
if (!input || !list) return;
const showList = (filterText: string = '') => {
if (!this.isEditMode) return;
const users = state.masterData.users || [];
const query = filterText.trim().toLowerCase();
const filtered = query
? users.filter((u: any) =>
u.user_name.toLowerCase().includes(query) ||
(u.dept_name && u.dept_name.toLowerCase().includes(query)) ||
(u.emp_no && u.emp_no.toLowerCase().includes(query))
)
: users;
if (filtered.length === 0) {
list.innerHTML = '<div class="autocomplete-item" style="color: #94a3b8; cursor: default;">일치하는 사원 없음</div>';
} else {
const seen = new Set();
const uniqueFiltered = filtered.filter((u: any) => {
const key = `${u.user_name}-${u.dept_name}-${u.emp_no}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
}).slice(0, 15);
list.innerHTML = uniqueFiltered.map((u: any) => `
<div class="autocomplete-item user-suggestion-item"
data-name="${u.user_name}"
data-dept="${u.dept_name || ''}"
data-pos="${u.position || ''}"
data-emp="${u.emp_no || ''}">
<div style="font-weight: 600; color: #1e293b;">${u.user_name}</div>
<div style="font-size: 0.75rem; color: #64748b; margin-top: 2px;">
${u.dept_name || '부서 없음'} / 사번: ${u.emp_no || '-'} / ${u.position || '직급 없음'}
</div>
</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('.user-suggestion-item');
if (item) {
const name = item.getAttribute('data-name') || '';
const dept = item.getAttribute('data-dept') || '';
const pos = item.getAttribute('data-pos') || '';
const emp = item.getAttribute('data-emp') || '';
input.value = name;
if (positionInput) positionInput.value = pos;
if (empNoInput) empNoInput.value = emp;
if (deptSelect && dept) {
for (let i = 0; i < deptSelect.options.length; i++) {
if (deptSelect.options[i].value === dept) {
deptSelect.selectedIndex = i;
break;
}
}
}
list.classList.add('hidden');
}
});
document.addEventListener('mousedown', (e) => {
if (e.target !== input && !list.contains(e.target as Node)) {
list.classList.add('hidden');
}
});
}
private renderHistory(assetId: string) {
const container = document.getElementById('hw-history-list');
if (!container) return;

View File

@@ -43,6 +43,7 @@ export const TYPE_PREFIX_MAP: Record<string, string> = {
'저장매체': 'STM', 'HDD': 'HDD', 'SSD': 'SSD',
'노트북': 'NBK', '태블릿': 'TAB',
'드론': 'DRO', '측량장비': 'SUR', '보조기기': 'SUR', '허브': 'NET',
'모니터': 'MON',
'구독SW': 'SW', '영구SW': 'SW', '내부' : 'SW_INT', '외부':'SW_EXT'
};

View File

@@ -59,11 +59,15 @@ export function renderNavigation(onTabChange: (tab: string) => void) {
Object.keys(MENU_CONFIG).forEach(catKey => {
const config = MENU_CONFIG[catKey];
const visibleTabs = config.tabs.filter((tab: string) => {
let visibleTabs = config.tabs.filter((tab: string) => {
if (state.currentUserRole === 'admin') return tab === '대시보드';
return tab !== '대시보드';
});
if (state.currentUserRole === 'admin' && catKey === 'hw') {
visibleTabs = ['대시보드', '실사 승인'];
}
if (visibleTabs.length === 0) return;
visibleTabs.forEach((tab: string) => {

View File

@@ -52,7 +52,7 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions)
};
container.innerHTML = `
<div class="search-item flex-1">
<div class="search-item keyword-search">
<label>${keywordLabel}</label>
<input type="text" id="filter-keyword" placeholder="검색어를 입력하세요..." autocomplete="off" value="${initialFilters.keyword || ''}">
</div>
@@ -108,6 +108,12 @@ export function renderFilterBar(container: HTMLElement, options: FilterOptions)
<button id="btn-reset-filters" class="btn btn-outline btn-reset">
<i data-lucide="refresh-ccw" class="icon-sm"></i> ${UI_TEXT.ACTION.RESET_FILTER}
</button>
<div class="search-item result-count-item">
<label>검색 결과</label>
<div class="result-count-box">
<span id="filter-total-count" class="filter-total-count result-count-text">0개</span>
</div>
</div>
${getActionButtonsHTML()}
`;

250
src/core/qr_print.ts Normal file
View File

@@ -0,0 +1,250 @@
export interface QRPrintItem {
type: 'asset' | 'location';
code: string;
title: string; // e.g. "[ HM IT ASSET ]" or "[ HM LOCATION ]"
subtitle?: string; // e.g. "가을-PC(i5-12400F)" or "기술개발센터 서버실"
dept?: string; // e.g. "전산" or "B-03 랙"
user?: string; // e.g. "박노석"
date?: string; // e.g. "2024-08-05"
}
/**
* QR 라벨 인쇄 유틸리티 클래스
*/
export class QRPrinter {
private static styleId = 'qr-print-style';
private static containerId = 'label-print-container';
/**
* 인쇄 전용 CSS 스타일 주입
*/
private static injectStyles() {
if (document.getElementById(this.styleId)) return;
const style = document.createElement('style');
style.id = this.styleId;
style.innerHTML = `
/* 화면에서는 인쇄 컨테이너 숨김 */
#${this.containerId} {
display: none;
}
@media print {
/* 화면 내 모든 요소 숨김 */
body > *:not(#${this.containerId}) {
display: none !important;
}
/* 인쇄 전용 컨테이너 표시 */
#${this.containerId} {
display: block !important;
position: absolute;
left: 0;
top: 0;
width: 50mm;
height: 30mm;
margin: 0;
padding: 0;
box-sizing: border-box;
background: #fff;
}
/* 페이지 규격 설정 */
@page {
size: 50mm 30mm;
margin: 0;
}
/* 개별 라벨 스타일 */
.print-label-item {
display: flex !important;
flex-direction: row;
width: 50mm;
height: 30mm;
box-sizing: border-box;
padding: 2.5mm;
page-break-after: always;
font-family: 'Pretendard Variable', sans-serif;
color: #000;
background: #fff;
overflow: hidden;
}
.print-label-item:last-child {
page-break-after: avoid;
}
/* 왼쪽 명세 영역 */
.label-details {
width: 30mm;
display: flex;
flex-direction: column;
justify-content: space-between;
font-size: 6.5pt;
line-height: 1.25;
text-align: left;
padding-right: 1mm;
word-break: break-all;
}
.label-header {
font-size: 7.5pt;
font-weight: 800;
border-bottom: 0.5px solid #000;
padding-bottom: 0.5mm;
margin-bottom: 0.5mm;
color: #000;
}
.label-row {
display: flex;
margin-bottom: 0.2mm;
}
.label-row .row-title {
font-weight: 700;
width: 9.5mm;
flex-shrink: 0;
}
.label-row .row-value {
flex: 1;
}
/* 오른쪽 QR 영역 */
.label-qr-wrapper {
width: 15mm;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
.label-qr-canvas {
width: 14mm !important;
height: 14mm !important;
display: block;
}
.label-qr-code-text {
font-size: 5.5pt;
font-weight: 700;
margin-top: 1mm;
text-align: center;
width: 15mm;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
`;
document.head.appendChild(style);
}
/**
* 라벨 인쇄 실행
*/
public static async print(items: QRPrintItem[]): Promise<void> {
if (items.length === 0) return;
this.injectStyles();
// 기존 컨테이너 제거
const oldContainer = document.getElementById(this.containerId);
if (oldContainer) oldContainer.remove();
// 새 인쇄 컨테이너 생성
const container = document.createElement('div');
container.id = this.containerId;
document.body.appendChild(container);
for (let i = 0; i < items.length; i++) {
const item = items[i];
const labelDiv = document.createElement('div');
labelDiv.className = 'print-label-item';
// QR 접속 URL 정의
const paramName = item.type === 'asset' ? 'asset' : 'loc';
const qrUrl = `${window.location.origin}/mobile?${paramName}=${encodeURIComponent(item.code)}`;
// HTML 구성
if (item.type === 'asset') {
labelDiv.innerHTML = `
<div class="label-details">
<div class="label-header">${item.title}</div>
<div class="label-row">
<span class="row-title">자산번호 :</span>
<span class="row-value">${item.code}</span>
</div>
<div class="label-row">
<span class="row-title">자 산 명 :</span>
<span class="row-value">${item.subtitle || '-'}</span>
</div>
<div class="label-row">
<span class="row-title">부 서 :</span>
<span class="row-value">${item.dept || '-'}</span>
</div>
<div class="label-row">
<span class="row-title">사 용 자 :</span>
<span class="row-value">${item.user || '-'}</span>
</div>
</div>
<div class="label-qr-wrapper">
<canvas class="label-qr-canvas" id="qr-canvas-${i}"></canvas>
<div class="label-qr-code-text">${item.code}</div>
</div>
`;
} else {
// Location 라벨 레이아웃
labelDiv.innerHTML = `
<div class="label-details" style="justify-content: center; gap: 1mm;">
<div class="label-header">${item.title}</div>
<div class="label-row">
<span class="row-title">위치코드 :</span>
<span class="row-value" style="font-weight: 700;">${item.code}</span>
</div>
<div class="label-row">
<span class="row-title">구 역 :</span>
<span class="row-value">${item.subtitle || '-'}</span>
</div>
<div class="label-row">
<span class="row-title">상세위치 :</span>
<span class="row-value">${item.dept || '-'}</span>
</div>
</div>
<div class="label-qr-wrapper">
<canvas class="label-qr-canvas" id="qr-canvas-${i}"></canvas>
<div class="label-qr-code-text">${item.code}</div>
</div>
`;
}
container.appendChild(labelDiv);
// QR 코드 렌더링
const canvas = document.getElementById(`qr-canvas-${i}`) as HTMLCanvasElement;
if (canvas) {
const qrLib = (window as any).QRCode;
if (qrLib) {
await qrLib.toCanvas(canvas, qrUrl, {
margin: 0,
width: 100,
errorCorrectionLevel: 'H'
});
} else {
console.error("QRCode library is not loaded on window.");
}
}
}
// 약간의 딜레이를 주어 QR 코드가 완전히 렌더링되도록 함
setTimeout(() => {
window.print();
// 인쇄 완료 후 컨테이너 정리
window.onafterprint = () => {
container.remove();
};
}, 250);
}
}

View File

@@ -45,6 +45,63 @@ export async function loadMasterDataFromDB() {
const data = await response.json();
// DB의 쪼개진 asset_remote 데이터로부터 가상 대표 속성(IP, MAC, 원격도구)을 주입해주는 전처리 함수
const preprocessAssets = (assets: any[]) => {
if (!Array.isArray(assets)) return;
assets.forEach((asset: any) => {
let ip = '';
let mac = '';
let remoteTool = '';
let remoteId = '';
let remotePw = '';
let rems: any[] = [];
try {
rems = asset.remotes ? (typeof asset.remotes === 'string' ? JSON.parse(asset.remotes) : asset.remotes) : [];
} catch(e) {}
if (Array.isArray(rems)) {
rems.forEach((r: any) => {
if (r.type === 'IP') {
if (!ip) ip = r.val1 || '';
if (r.val2) {
if (String(r.val2).trim().startsWith('{')) {
try {
const parsed = JSON.parse(r.val2);
remoteTool = r.name || '원격접속';
remoteId = parsed.id || '';
remotePw = parsed.pw || '';
} catch(e) {}
} else {
if (!mac) mac = r.val2 || '';
}
}
} else if (r.type === 'MAC') {
if (!mac) mac = r.val1 || '';
} else if (r.type === 'REMOTE') {
if (!remoteTool) remoteTool = r.name || '';
if (!remoteId) remoteId = r.val1 || '';
if (!remotePw) remotePw = r.val2 || '';
}
});
}
// 최상위 가상 속성 바인딩 (목록 및 위치보기 뷰어 매핑용)
asset.ip_address = ip;
asset.mac_address = mac;
asset.remote_tool = remoteTool;
asset.remote_id = remoteId;
asset.remote_pw = remotePw;
});
};
if (data) {
const keys = ['pc', 'server', 'storage', 'network', 'survey', 'equipment', 'officeSupplies'];
keys.forEach(k => {
if (data[k]) preprocessAssets(data[k]);
});
}
// 전역 상태 업데이트
state.masterData = {
...state.masterData,

View File

@@ -5,6 +5,7 @@ import { renderNavigation } from './components/Navigation';
import { renderDashboard } from './views/DashboardView';
import { renderSWTable } from './views/SW_Table';
import { renderLocationView } from './views/LocationView';
import { renderAuditApprovalView } from './views/AuditApprovalView';
import { initBaseModal } from './components/Modal/BaseModal';
import { initHwModal, openHwModal } from './components/Modal/HWModal';
import { initSwModal, openSwModal } from './components/Modal/SWModal';
@@ -32,6 +33,11 @@ function refreshView(tab?: string) {
return;
}
if (activeTab === '실사 승인') {
renderAuditApprovalView(mainContent);
return;
}
// 서버 탭이 아닐 경우에는 state.viewMode가 location이더라도 강제로 목록(list) 뷰를 그리도록 함
// (state.viewMode의 원래 상태는 보존하여, 서버 탭 복귀 시 최근 보던 모드를 유지함)
const isServerTab = activeTab === '서버';

183
src/mobile-main.ts Normal file
View File

@@ -0,0 +1,183 @@
// ITAM Mobile Audit Scanner Main Business Logic
const SESSION_LOC_KEY = 'itam_audit_locked_location';
document.addEventListener('DOMContentLoaded', () => {
const locDisplay = document.getElementById('loc-display')!;
const unlockBtn = document.getElementById('btn-unlock-loc') as HTMLButtonElement;
const feedbackEl = document.getElementById('scan-feedback')!;
const manualToggleBtn = document.getElementById('btn-toggle-manual')!;
const manualForm = document.getElementById('manual-form')!;
const manualInput = document.getElementById('manual-code-input') as HTMLInputElement;
const manualSubmitBtn = document.getElementById('btn-submit-manual') as HTMLButtonElement;
let html5QrcodeScanner: any = null;
// Initialize UI based on current session lock
updateLocationUI();
// Parse URL parameters for immediate processing (convenience for direct QR scans)
parseUrlParams();
// Initialize HTML5 QR Code Scanner
initScanner();
// Bind UI Events
unlockBtn.addEventListener('click', () => {
sessionStorage.removeItem(SESSION_LOC_KEY);
showFeedback('위치 잠금이 해제되었습니다.', 'success');
updateLocationUI();
});
manualToggleBtn.addEventListener('click', () => {
const isHidden = window.getComputedStyle(manualForm).display === 'none';
manualForm.style.display = isHidden ? 'flex' : 'none';
manualToggleBtn.textContent = isHidden ? '스캐너 카메라로 스캔하기' : '카메라가 안 되나요? 수동 코드로 입력';
});
manualSubmitBtn.addEventListener('click', () => {
const code = manualInput.value.trim();
if (!code) return;
processScannedCode(code);
manualInput.value = '';
});
// --- Core Scanner Functions ---
function initScanner() {
try {
// Create Html5Qrcode instance
// Using Html5Qrcode directly instead of Html5QrcodeScanner for customized viewport control
const html5QrCode = new (window as any).Html5Qrcode("reader");
const config = {
fps: 10,
qrbox: (width: number, height: number) => {
const size = Math.min(width, height) * 0.7;
return { width: size, height: size };
},
aspectRatio: 1.0
};
// Start scanning using the rear camera
html5QrCode.start(
{ facingMode: "environment" },
config,
(decodedText: string) => {
processScannedCode(decodedText);
},
(errorMessage: string) => {
// Silent failure during continuous scanning to avoid log flooding
}
).catch((err: any) => {
console.error("Camera startup failed:", err);
showFeedback("카메라 시작 실패: 권한을 허용했는지 확인하세요.", "error");
});
} catch (e) {
console.error("Failed to initialize html5-qrcode:", e);
showFeedback("QR 라이브러리 로드 오류", "error");
}
}
function processScannedCode(rawCode: string) {
// QR 코드 인쇄 폼 등으로 인한 개행 문자(\r, \n) 및 모든 공백 문자(\s)를 제거
const code = rawCode.replace(/[\r\n]/g, '').replace(/\s+/g, '').trim();
// 1. Check if the code is a physical location code
if (code.startsWith('LOC-')) {
sessionStorage.setItem(SESSION_LOC_KEY, code);
showFeedback(`위치 [${code}] 잠금 설정 완료!`, 'success');
updateLocationUI();
vibrateDevice(100);
return;
}
// 2. Otherwise treat it as an asset code
const lockedLoc = sessionStorage.getItem(SESSION_LOC_KEY);
if (!lockedLoc) {
showFeedback('위치 QR 코드를 먼저 스캔하여 잠금을 설정해야 자산을 스캔할 수 있습니다.', 'error');
vibrateDevice([100, 50, 100]);
return;
}
// Submit matching info to server
submitAssetAudit(code, lockedLoc);
}
async function submitAssetAudit(assetCode: string, locationCode: string) {
showFeedback(`자산 ${assetCode} 전송 중...`, 'success');
try {
// Request is sent relative to host, which resolves dynamically through server proxy
const res = await fetch('/api/audit/scan', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
asset_code: assetCode,
physical_location_code: locationCode
})
});
const data = await res.json();
if (res.ok && data.success) {
showFeedback(`자산 [${assetCode}] 실사 전송 성공! (관리자 승인 대기)`, 'success');
vibrateDevice([200]);
} else {
showFeedback(`전송 실패: ${data.error || '알 수 없는 서버 오류'}`, 'error');
vibrateDevice([100, 100, 100]);
}
} catch (err) {
console.error("Failed to submit scan:", err);
showFeedback('서버 전송 중 통신 네트워크 오류가 발생했습니다.', 'error');
vibrateDevice([100, 100, 100]);
}
}
function updateLocationUI() {
const lockedLoc = sessionStorage.getItem(SESSION_LOC_KEY);
if (lockedLoc) {
locDisplay.textContent = lockedLoc;
locDisplay.className = 'badge-lock';
unlockBtn.style.display = 'inline-block';
} else {
locDisplay.textContent = '위치 QR 코드를 먼저 스캔하세요.';
locDisplay.className = 'badge-empty';
unlockBtn.style.display = 'none';
}
}
function parseUrlParams() {
const params = new URLSearchParams(window.location.search);
const loc = params.get('loc');
const asset = params.get('asset');
if (loc) {
processScannedCode(loc);
// Clean query parameters to avoid re-triggering on page refresh
window.history.replaceState({}, document.title, window.location.pathname);
} else if (asset) {
processScannedCode(asset);
window.history.replaceState({}, document.title, window.location.pathname);
}
}
function showFeedback(msg: string, type: 'success' | 'error') {
feedbackEl.textContent = msg;
feedbackEl.style.display = 'block';
feedbackEl.className = `feedback-message ${type === 'success' ? 'feedback-success' : 'feedback-error'}`;
// Auto-hide feedback after 4 seconds
setTimeout(() => {
if (feedbackEl.textContent === msg) {
feedbackEl.style.display = 'none';
}
}, 4000);
}
function vibrateDevice(pattern: number | number[]) {
if ('vibrate' in navigator) {
navigator.vibrate(pattern);
}
}
});

View File

@@ -640,3 +640,28 @@ input:checked + .role-slider:before {
letter-spacing: -0.02em;
}
/* --- Filter Bar Unified Layout Refactoring --- */
.search-item.keyword-search {
flex: 0 0 320px;
}
.search-item.result-count-item {
justify-content: flex-end;
}
.result-count-box {
height: clamp(34px, 4.5vmin, 44px);
display: flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
min-width: 60px;
}
.result-count-text {
white-space: nowrap;
color: var(--color-blue);
font-size: var(--fs-sm);
font-weight: 700;
}

View File

@@ -0,0 +1,427 @@
import { state, loadMasterDataFromDB } from '../core/state';
import { openHwModal } from '../components/Modal/HWModal';
/**
* 실사 점검 승인 대시보드 뷰 (Vercel Style Clean layout)
*/
export async function renderAuditApprovalView(container: HTMLElement) {
if (!container) return;
// 1. CSS Stylesheet Injection
const styleId = 'audit-approval-view-style';
if (!document.getElementById(styleId)) {
const style = document.createElement('style');
style.id = styleId;
style.innerHTML = `
.audit-container {
display: flex;
flex-direction: column;
height: calc(100vh - var(--header-height) - 48px);
background-color: var(--canvas);
color: var(--text-main);
padding: 1.5rem;
box-sizing: border-box;
}
.audit-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
flex-shrink: 0;
}
.audit-title-area {
display: flex;
align-items: center;
gap: 0.75rem;
}
.audit-title {
font-size: 1.25rem;
font-weight: 700;
}
.audit-badge {
background-color: var(--primary-soft);
color: var(--primary);
font-size: 0.75rem;
font-weight: 700;
padding: 0.15rem 0.5rem;
border-radius: 9999px;
border: 1px solid rgba(59, 130, 246, 0.2);
}
.audit-actions {
display: flex;
gap: 0.5rem;
}
.audit-btn {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
font-weight: 600;
border-radius: 8px;
cursor: pointer;
transition: all 0.2s;
border: 1px solid var(--hairline);
background-color: var(--canvas-soft);
color: var(--text-main);
}
.audit-btn:hover {
background-color: var(--canvas-soft-2);
}
.audit-btn-primary {
background-color: var(--primary);
color: #fff;
border-color: var(--primary);
}
.audit-btn-primary:hover {
background-color: var(--primary-hover);
}
.audit-btn-danger {
background-color: rgba(239, 68, 68, 0.1);
color: var(--danger);
border-color: rgba(239, 68, 68, 0.2);
}
.audit-btn-danger:hover {
background-color: rgba(239, 68, 68, 0.2);
}
/* Data Table Custom Vercel layout */
.audit-table-wrapper {
flex: 1;
overflow: auto;
border: 1px solid var(--hairline);
border-radius: 12px;
background-color: var(--canvas-soft);
}
.audit-table {
width: 100%;
border-collapse: collapse;
text-align: left;
font-size: 0.825rem;
}
.audit-table th {
background-color: var(--canvas-soft-2);
color: var(--text-muted);
font-weight: 600;
padding: 0.6rem 0.8rem;
border-bottom: 1px solid var(--hairline);
position: sticky;
top: 0;
z-index: 10;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.audit-table td {
padding: 0.75rem 0.8rem;
border-bottom: 1px solid var(--hairline);
vertical-align: middle;
}
.audit-table tr:last-child td {
border-bottom: none;
}
.audit-table tr:hover td {
background-color: var(--canvas-soft-2);
}
.audit-checkbox {
width: 15px;
height: 15px;
cursor: pointer;
accent-color: var(--primary);
}
.link-asset-code {
color: var(--primary);
text-decoration: underline;
font-weight: 700;
cursor: pointer;
}
.link-asset-code:hover {
color: var(--primary-hover);
}
.location-badge-diff {
background-color: rgba(245, 158, 11, 0.12);
color: #d97706;
border: 1px solid rgba(245, 158, 11, 0.25);
padding: 0.15rem 0.4rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
display: inline-block;
}
.location-badge-same {
background-color: rgba(16, 185, 129, 0.08);
color: #059669;
border: 1px solid rgba(16, 185, 129, 0.18);
padding: 0.15rem 0.4rem;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
display: inline-block;
}
/* Empty State Illustration Layout */
.audit-empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 3rem;
gap: 1rem;
height: 100%;
color: var(--text-muted);
}
.audit-empty-icon {
font-size: 3rem;
color: var(--hairline);
}
`;
document.head.appendChild(style);
}
let pendingData: any[] = [];
// Function to load data and render layout
async function loadAndRender() {
try {
container.innerHTML = `
<div class="audit-container">
<div class="audit-header">
<div class="audit-title-area">
<span class="audit-title">실사 점검 승인 관리</span>
<span id="audit-count-badge" class="audit-badge">조회 중...</span>
</div>
<div class="audit-actions">
<button id="btn-audit-refresh" class="audit-btn"><i data-lucide="refresh-ccw" style="width:14px; height:14px; margin-right:4px;"></i> 새로고침</button>
<button id="btn-audit-reject" class="audit-btn audit-btn-danger" disabled>선택 반려</button>
<button id="btn-audit-approve" class="audit-btn audit-btn-primary" disabled>선택 승인</button>
</div>
</div>
<div id="audit-content-area" class="audit-table-wrapper">
<div style="padding: 2rem; text-align: center; color: var(--text-muted);">실사 내역을 불러오고 있습니다...</div>
</div>
</div>
`;
bindHeaderEvents();
await fetchPendingList();
} catch (err) {
console.error('Failed to init audit view:', err);
}
}
async function fetchPendingList() {
try {
const res = await fetch('/api/audit/pending');
pendingData = await res.json();
renderTable();
} catch (err) {
console.error('Failed to fetch pending audits:', err);
const contentArea = document.getElementById('audit-content-area')!;
contentArea.innerHTML = `<div style="padding: 3rem; text-align: center; color: var(--danger); font-weight: 600;">데이터를 불러오는 중 네트워크 에러가 발생했습니다.</div>`;
}
}
function renderTable() {
const badge = document.getElementById('audit-count-badge')!;
badge.textContent = `대기 ${pendingData.length}`;
const contentArea = document.getElementById('audit-content-area')!;
if (pendingData.length === 0) {
contentArea.innerHTML = `
<div class="audit-empty-state">
<div class="audit-empty-icon">✓</div>
<div style="font-size: 1.05rem; font-weight: 700; color: var(--text-main);">대기 중인 실사 내역이 없습니다</div>
<div style="font-size: 0.8rem;">현장에서 스캐너로 자산을 스캔하면 실시간으로 여기에 등록됩니다.</div>
</div>
`;
updateActionButtons();
return;
}
let tbodyRows = '';
pendingData.forEach((row, i) => {
// Format scanned date
const dateStr = new Date(row.scanned_at).toLocaleString('ko-KR');
// Check if location actually changed
const oldLocFull = row.old_location ? `${row.old_location} ${row.old_location_detail || ''}`.trim() : '미배치';
const newLocFull = `${row.location_name} ${row.location_detail || ''}`.trim();
const isDiff = oldLocFull !== newLocFull;
tbodyRows += `
<tr>
<td style="width: 40px; text-align: center;">
<input type="checkbox" class="audit-checkbox row-select" data-id="${row.id}" />
</td>
<td>
<span class="link-asset-code" data-index="${i}">${row.asset_code}</span>
</td>
<td>${row.asset_purpose || '-'}</td>
<td><span class="badge" style="font-size: 11px;">${row.asset_type || 'IT자산'}</span></td>
<td><span style="color: var(--text-muted);">${oldLocFull}</span></td>
<td>
<span class="${isDiff ? 'location-badge-diff' : 'location-badge-same'}">${newLocFull}</span>
</td>
<td style="color: var(--text-muted); font-size: 11px;">${dateStr}</td>
</tr>
`;
});
contentArea.innerHTML = `
<table class="audit-table">
<thead>
<tr>
<th style="width: 40px; text-align: center;">
<input type="checkbox" class="audit-checkbox" id="chk-audit-all" />
</th>
<th>자산번호</th>
<th>자산용도</th>
<th>자산유형</th>
<th>기존 위치</th>
<th>실사 위치</th>
<th>스캔 일시</th>
</tr>
</thead>
<tbody>
${tbodyRows}
</tbody>
</table>
`;
bindTableEvents();
updateActionButtons();
}
function bindHeaderEvents() {
document.getElementById('btn-audit-refresh')?.addEventListener('click', () => fetchPendingList());
document.getElementById('btn-audit-approve')?.addEventListener('click', () => handleAction('approve'));
document.getElementById('btn-audit-reject')?.addEventListener('click', () => handleAction('reject'));
}
function bindTableEvents() {
// Select All Checkbox
const selectAllChk = document.getElementById('chk-audit-all') as HTMLInputElement;
const rowCheckboxes = document.querySelectorAll('.row-select') as NodeListOf<HTMLInputElement>;
selectAllChk?.addEventListener('change', () => {
rowCheckboxes.forEach(chk => {
chk.checked = selectAllChk.checked;
});
updateActionButtons();
});
rowCheckboxes.forEach(chk => {
chk.addEventListener('change', () => {
updateActionButtons();
// Sync selectAll checkbox state
const allChecked = Array.from(rowCheckboxes).every(c => c.checked);
if (selectAllChk) selectAllChk.checked = allChecked;
});
});
// Asset Detail Modal linkage
const assetLinks = document.querySelectorAll('.link-asset-code');
assetLinks.forEach(link => {
link.addEventListener('click', (e) => {
const idx = parseInt((e.target as HTMLElement).dataset.index!);
const row = pendingData[idx];
if (!row) return;
// Compile master array from state data to find full asset object
const allHwAssets = [
...(state.masterData.pc || []),
...(state.masterData.server || []),
...(state.masterData.storage || []),
...(state.masterData.network || []),
...(state.masterData.equipment || []),
...(state.masterData.survey || []),
...(state.masterData.officeSupplies || []),
...(state.masterData.pcParts || [])
];
const targetAsset = allHwAssets.find(a => a.asset_code === row.asset_code);
if (targetAsset) {
openHwModal(targetAsset, 'view');
} else {
alert(`자산 코드 [${row.asset_code}] 에 매칭되는 마스터 데이터가 존재하지 않습니다.`);
}
});
});
}
function updateActionButtons() {
const selected = document.querySelectorAll('.row-select:checked');
const approveBtn = document.getElementById('btn-audit-approve') as HTMLButtonElement;
const rejectBtn = document.getElementById('btn-audit-reject') as HTMLButtonElement;
if (approveBtn && rejectBtn) {
const isDisabled = selected.length === 0;
approveBtn.disabled = isDisabled;
rejectBtn.disabled = isDisabled;
approveBtn.textContent = `선택 승인 (${selected.length})`;
rejectBtn.textContent = `선택 반려 (${selected.length})`;
}
}
async function handleAction(actionType: 'approve' | 'reject') {
const selected = document.querySelectorAll('.row-select:checked') as NodeListOf<HTMLInputElement>;
const ids = Array.from(selected).map(chk => parseInt(chk.dataset.id!));
if (ids.length === 0) return;
const actionText = actionType === 'approve' ? '승인' : '반려';
if (!confirm(`선택한 ${ids.length}건의 실사 내역을 최종 ${actionText} 처리할까요?`)) return;
const endpoint = actionType === 'approve' ? '/api/audit/approve' : '/api/audit/reject';
try {
const res = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
pending_ids: ids,
processed_by: 'ADMIN'
})
});
const data = await res.json();
if (res.ok && data.success) {
alert(`성공적으로 ${actionText} 완료되었습니다.`);
// Reload dashboard state to sync map_config/db coordinates changes
await loadMasterDataFromDB();
await fetchPendingList();
} else {
alert(`${actionText} 실패: ${data.error || '알 수 없는 서버 오류'}`);
}
} catch (err) {
console.error(`Failed to trigger audit ${actionType}:`, err);
alert(`네트워크 통신 오류로 ${actionText} 처리가 실패했습니다.`);
}
}
// Run initial loading
await loadAndRender();
}

View File

@@ -196,11 +196,14 @@
height: auto;
object-fit: contain;
display: block;
position: relative;
z-index: 1;
}
.map-overlay {
position: absolute;
pointer-events: none;
pointer-events: auto;
z-index: 2;
}
.no-map-message {
@@ -216,6 +219,7 @@
align-items: center;
justify-content: center;
transition: all 0.2s ease;
z-index: 3;
}
/* --- Asset Detail Sidebar --- */

View File

@@ -11,9 +11,9 @@ export function renderEquipmentList(container: HTMLElement) {
searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'ASSET_MFR', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.ASSET_MFR.ui})`,
showLoc: true,
showDept: true,
showType: true
showType: true,
showStatus: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [

View File

@@ -7,13 +7,13 @@ import { createListView } from './ListFactory';
export function renderFacilityList(container: HTMLElement) {
createListView(container, {
title: '사무가구',
dataSource: () => sortAssets(state.masterData.equipment?.filter((a: any) => a.category === '시설자산') || []),
dataSource: () => sortAssets(state.masterData.officeSupplies || []),
searchKeys: ['MODEL_NAME', 'ASSET_MFR', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui})`,
showLoc: true,
showDept: true,
showType: true
showType: true,
showStatus: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [

View File

@@ -6,13 +6,14 @@ import { createListView } from './ListFactory';
export function renderGiftList(container: HTMLElement) {
createListView(container, {
title: '선물',
dataSource: () => sortAssets(state.masterData.equipment?.filter((a: any) => a.category === '선물') || []),
dataSource: () => sortAssets(state.masterData.vip || []),
searchKeys: ['PRODUCT_NAME', 'MODEL_NAME', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.PRODUCT_NAME.ui})`,
showCorp: true,
showDept: true,
showType: true
showType: true,
showStatus: true
},
onRowClick: () => alert('상세 정보 준비 중입니다.'),
columns: [

View File

@@ -77,7 +77,7 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
const fetchMapConfig = async () => {
try {
const res = await fetch(`http://${location.hostname}:3000/api/maps`);
const res = await fetch('/api/maps');
dynamicMapConfig = await res.json();
} catch (err) { console.error('Failed to fetch map config:', err); }
};
@@ -667,10 +667,15 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
let filtered = applyCommonFilters(fullList, currentFilters, config.searchKeys as any[]);
if (sortState.key) filtered = dynamicSort(filtered, sortState.key, sortState.direction);
const totalCountEl = filterBar.querySelector('#filter-total-count');
if (totalCountEl) {
totalCountEl.textContent = `${filtered.length}`;
}
thead.innerHTML = `<tr>${config.columns.map(col => {
const isDateCol = col.header.includes('일') || col.header.includes('날짜') || col.header.includes('연월');
const alignmentClass = col.align ? `text-${col.align}` : (isDateCol ? 'text-center' : '');
return `<th class="${alignmentClass}" ${col.sortKey ? `data-sort="${col.sortKey}"` : ''} style="${col.width ? `width:${col.width};` : ''}">${col.header}</th>`;
return `<th class="${alignmentClass}" ${col.sortKey ? `data-sort="${col.sortKey}"` : ''} style="${col.width ? `width:${col.width};` : ''}">${col.header}<div class="resizer"></div></th>`;
}).join('')}</tr>`;
tbody.innerHTML = filtered.length === 0 ? `<tr><td colspan="${config.columns.length}" class="text-center empty-cell">${UI_TEXT.MESSAGES.NO_DATA}</td></tr>`
@@ -681,13 +686,78 @@ export function createListView(container: HTMLElement, config: ListViewConfig) {
const rendered = col.render(asset);
const rawText = rendered.replace(/<[^>]*>/g, '').trim();
const titleAttr = rawText && rawText !== '-' ? `title="${rawText.replace(/"/g, '&quot;')}"` : '';
return `<td class="${alignmentClass} ${customClass}" style="${col.width ? `width:${col.width};` : ''}" ${titleAttr}>${rendered}</td>`;
let displayContent = rendered;
if (col.header === ASSET_SCHEMA.LOCATION.ui && asset.is_audit_approved) {
const justify = col.align === 'center' ? 'center' : (col.align === 'right' ? 'flex-end' : 'flex-start');
displayContent = `
<div style="display: inline-flex; align-items: center; gap: 6px; justify-content: ${justify}; width: 100%;">
<span>${rendered}</span>
<span style="display: inline-flex; align-items: center; background-color: rgba(16, 185, 129, 0.08); color: #059669; border: 1px solid rgba(16, 185, 129, 0.18); padding: 1px 4px; border-radius: 4px; font-size: 10px; font-weight: 600; white-space: nowrap; line-height: 1.2; vertical-align: middle;">승인완료</span>
</div>
`;
}
return `<td class="${alignmentClass} ${customClass}" ${titleAttr}>${displayContent}</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 }; updateTable(); });
makeColumnsResizable(table);
};
function makeColumnsResizable(tableElement: HTMLTableElement) {
const headers = tableElement.querySelectorAll('th');
headers.forEach(th => {
const resizer = th.querySelector('.resizer') as HTMLElement;
if (!resizer) return;
let startX = 0;
let startWidth = 0;
let startTableWidth = 0;
const onMouseMove = (e: MouseEvent) => {
const dx = e.clientX - startX;
const newWidth = Math.max(50, startWidth + dx);
// Update the width of the dragged column
th.style.width = `${newWidth}px`;
// Dynamically adjust the total table width by the delta change,
// preventing neighboring columns from shrinking or expanding.
const deltaW = newWidth - startWidth;
tableElement.style.width = `${startTableWidth + deltaW}px`;
};
const onMouseUp = () => {
resizer.classList.remove('resizing');
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
resizer.addEventListener('mousedown', (e: MouseEvent) => {
// Prevents header click sorting trigger from firing
e.stopPropagation();
e.preventDefault();
// Freeze all columns at their current pixel width before dragging
headers.forEach(header => {
header.style.width = `${header.offsetWidth}px`;
});
startX = e.clientX;
startWidth = th.offsetWidth;
// Capture the initial physical width of the entire table
startTableWidth = tableElement.offsetWidth;
resizer.classList.add('resizing');
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
});
});
}
const switchView = () => {
contentWrapper.innerHTML = '';
const isAssetMode = !isServer || state.viewMode === 'list';

View File

@@ -13,7 +13,8 @@ export function renderMobileList(container: HTMLElement) {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui})`,
showCorp: true,
showDept: true,
showType: true
showType: true,
showStatus: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [

View File

@@ -11,9 +11,9 @@ export function renderNetworkList(container: HTMLElement) {
searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'ASSET_MFR', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.ASSET_MFR.ui})`,
showLoc: true,
showDept: true,
showType: true
showType: true,
showStatus: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [

View File

@@ -28,7 +28,6 @@ export function renderPcList(container: HTMLElement) {
searchKeys: ['CURRENT_DEPT', 'CURRENT_USER', 'MODEL_NAME', 'MAC_ADDR', 'MANAGER_MAIN', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.MANAGER_MAIN.ui}/${ASSET_SCHEMA.CURRENT_USER.ui})`,
showLoc: true,
showDept: true,
showType: true,
showStatus: true
@@ -50,7 +49,15 @@ export function renderPcList(container: HTMLElement) {
return `<span class="badge ${badgeClass}">${status}</span>`;
}
},
{ header: ASSET_SCHEMA.CURRENT_USER.ui, sortKey: ASSET_SCHEMA.CURRENT_USER.key, align: 'center', render: a => a[ASSET_SCHEMA.CURRENT_USER.key] || '-' },
{
header: '사용자',
sortKey: ASSET_SCHEMA.CURRENT_USER.key,
align: 'center',
render: a => {
const status = a[ASSET_SCHEMA.HW_STATUS.key] || '재고';
return (status === '재고' ? a[ASSET_SCHEMA.PREV_USER.key] : a[ASSET_SCHEMA.CURRENT_USER.key]) || '-';
}
},
{ header: ASSET_SCHEMA.USER_POSITION.ui, sortKey: ASSET_SCHEMA.USER_POSITION.key, align: 'center', render: a => a[ASSET_SCHEMA.USER_POSITION.key] || '-' },
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
{ header: ASSET_SCHEMA.CPU.ui, sortKey: ASSET_SCHEMA.CPU.key, align: 'center', render: a => a[ASSET_SCHEMA.CPU.key] || '' },

View File

@@ -7,13 +7,13 @@ import { createListView } from './ListFactory';
export function renderPcPartList(container: HTMLElement) {
createListView(container, {
title: 'PC부품',
dataSource: () => sortAssets(state.masterData.equipment?.filter((a: any) => a.category === 'PC부품') || []),
dataSource: () => sortAssets(state.masterData.pcParts || []),
searchKeys: ['MODEL_NAME', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui})`,
showLoc: true,
showDept: true,
showType: true
showType: true,
showStatus: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [
@@ -24,7 +24,6 @@ export function renderPcPartList(container: HTMLElement) {
render: a => `<span class="badge badge-success">${a[ASSET_SCHEMA.HW_STATUS.key] || '보관중'}</span>`
},
{ header: ASSET_SCHEMA.ASSET_TYPE.ui, sortKey: ASSET_SCHEMA.ASSET_TYPE.key, align: 'center', width: '10%', render: a => a[ASSET_SCHEMA.ASSET_TYPE.key] || '-' },
{ header: ASSET_SCHEMA.ASSET_MFR.ui, sortKey: ASSET_SCHEMA.ASSET_MFR.key, align: 'center', render: a => a[ASSET_SCHEMA.ASSET_MFR.key] || '' },
{ header: ASSET_SCHEMA.MODEL_NAME.ui, sortKey: ASSET_SCHEMA.MODEL_NAME.key, render: a => formatInline(a[ASSET_SCHEMA.MODEL_NAME.key] || '-') },
{ header: ASSET_SCHEMA.VOLUME.ui, sortKey: ASSET_SCHEMA.VOLUME.key, align: 'center', render: a => a[ASSET_SCHEMA.VOLUME.key] || '-' },
{ header: ASSET_SCHEMA.MONITOR_INCH.ui, sortKey: ASSET_SCHEMA.MONITOR_INCH.key, align: 'center', render: a => a[ASSET_SCHEMA.MONITOR_INCH.key] || '-' },

View File

@@ -7,13 +7,13 @@ import { createListView } from './ListFactory';
export function renderSpaceInfoList(container: HTMLElement) {
createListView(container, {
title: '공간정보장비',
dataSource: () => sortAssets(state.masterData.equipment?.filter((a: any) => a.category === '공간정보장비') || []),
dataSource: () => sortAssets(state.masterData.survey || []),
searchKeys: ['MODEL_NAME', 'PRODUCT_NAME', 'CURRENT_USER', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.CURRENT_USER.ui})`,
showLoc: true,
showDept: true,
showType: true
showType: true,
showStatus: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [

View File

@@ -11,9 +11,9 @@ export function renderStorageList(container: HTMLElement) {
searchKeys: ['MODEL_NAME', 'CURRENT_USER', 'SERIAL_NUM', 'ASSET_TYPE'],
filterOptions: {
keywordLabel: `통합 검색 (${ASSET_SCHEMA.MODEL_NAME.ui}/${ASSET_SCHEMA.CURRENT_USER.ui})`,
showLoc: true,
showDept: true,
showType: true
showType: true,
showStatus: true
},
onRowClick: (asset) => openHwModal(asset, 'view'),
columns: [

View File

@@ -39,9 +39,11 @@
table {
width: 100%;
min-width: 100%;
max-width: none;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed; /* Force fixed layout to prevent horizontal scroll */
table-layout: fixed;
}
th, td {
@@ -68,6 +70,25 @@ th {
letter-spacing: -0.02em;
box-shadow: inset 0 -1px 0 var(--hairline);
text-align: center; /* Set default header alignment to center */
position: relative; /* Essential for absolute positioning of resizer handles */
}
.resizer {
position: absolute;
right: 0;
top: 0;
width: 6px;
height: 100%;
cursor: col-resize;
user-select: none;
z-index: 60; /* Higher than thead's sticky z-index (50) to catch mouse events */
}
.resizer:hover,
.resizer.resizing {
background-color: var(--primary, #1e5149);
opacity: 0.8;
width: 3px;
}
td {

View File

@@ -241,8 +241,11 @@ export async function renderLocationView(container: HTMLElement) {
<span class="service-type-badge">${asset.service_type || '운영'}</span>
<span class="asset-type-label">${asset.asset_type || 'PC'}</span>
</div>
<div style="display: inline-flex; align-items: center; gap: 0.5rem;">
${asset.is_audit_approved ? `<span style="display: inline-flex; align-items: center; background-color: rgba(16, 185, 129, 0.08); color: #059669; border: 1px solid rgba(16, 185, 129, 0.18); padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: 600; height: 18px; line-height: 1; white-space: nowrap; vertical-align: middle;">승인완료</span>` : ''}
<button id="btn-view-from-loc" class="btn btn-primary btn-sm">조회</button>
</div>
</div>
`;
const fields = [

View File

@@ -1,5 +1,6 @@
import { IMAGE_LOCATIONS } from '../components/Modal/SharedData';
import { createIcons, X, Save, Trash2, ChevronLeft, ChevronRight } from 'lucide';
import { QRPrinter } from '../core/qr_print';
export class MapEditor {
private container: HTMLElement;
@@ -42,7 +43,7 @@ export class MapEditor {
private async loadAssets() {
try {
const res = await fetch(`http://${location.hostname}:3000/api/assets/master`);
const res = await fetch('/api/assets/master');
const masterData = await res.json();
const allHw = [
...(masterData.pc || []),
@@ -95,7 +96,7 @@ export class MapEditor {
private async loadConfig() {
try {
const res = await fetch(`http://${location.hostname}:3000/api/maps`);
const res = await fetch('/api/maps');
this.allMapConfig = await res.json();
} catch (err) {
console.error('Failed to load config:', err);
@@ -185,6 +186,30 @@ export class MapEditor {
}
});
document.getElementById('btn-print-map-qrs')?.addEventListener('click', () => {
if (this.boxes.length === 0) {
alert('인쇄할 구역이 없습니다.');
return;
}
const cleanKey = getCleanMapKey(this.currentPath);
const locName = getLocationName(this.currentPath);
const items = this.boxes.map((box, index) => {
const padIdx = String(index + 1).padStart(3, '0');
const locCode = `LOC-${cleanKey}-${padIdx}`;
const locDetail = getLocationDetail(this.currentPath, index);
return {
type: 'location' as const,
code: locCode,
title: '[ HM LOCATION ]',
subtitle: locName,
dept: locDetail
};
});
QRPrinter.print(items);
});
document.getElementById('btn-save-server')?.addEventListener('click', () => this.saveToServer());
}
@@ -195,7 +220,7 @@ export class MapEditor {
this.saveBtn.disabled = true;
this.saveBtn.textContent = '저장 중...';
const res = await fetch(`http://${location.hostname}:3000/api/maps/save`, {
const res = await fetch('/api/maps/save', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: this.currentPath, boxes: this.boxes })
@@ -248,8 +273,11 @@ export class MapEditor {
item.innerHTML = `
<div class="box-header">
<span class="box-index">#${i+1}</span>
<div style="display: flex; gap: 0.5rem; align-items: center;">
<button class="btn btn-outline btn-sm" onclick="printBoxQR(${i})" style="padding: 2px 6px; font-size: 11px; margin: 0; cursor: pointer;">QR</button>
<button class="btn-del" onclick="removeBox(${i})">×</button>
</div>
</div>
<div class="box-inputs margin-bottom">
<select data-index="${i}" data-prop="asset_id">
${optionsHtml}
@@ -294,5 +322,46 @@ export class MapEditor {
}
});
});
(window as any).printBoxQR = (index: number) => {
const box = this.boxes[index];
if (!box) return;
const cleanKey = getCleanMapKey(this.currentPath);
const padIdx = String(index + 1).padStart(3, '0');
const locCode = `LOC-${cleanKey}-${padIdx}`;
const locDetail = getLocationDetail(this.currentPath, index);
const locName = getLocationName(this.currentPath);
QRPrinter.print([{
type: 'location',
code: locCode,
title: '[ HM LOCATION ]',
subtitle: locName,
dept: locDetail
}]);
};
}
}
function getCleanMapKey(path: string) {
let clean = path.replace('img/location_photo/', '').replace('.png', '');
clean = clean.replace('서관', 'W').replace('동관', 'E');
clean = clean.replace('한맥빌딩/MDF실/MDF_', 'HAN-MDF-');
clean = clean.replace('기술개발센터/서버실/서버실_', 'DEV-SVR-');
clean = clean.replace(/\//g, '-');
return clean;
}
function getLocationName(path: string) {
if (path.includes('IDC')) return 'IDC';
if (path.includes('한맥빌딩')) return '한맥빌딩';
if (path.includes('기술개발센터')) return '기술개발센터';
return '기타';
}
function getLocationDetail(path: string, idx: number) {
let clean = path.replace('img/location_photo/', '').replace('.png', '');
let parts = clean.split('/');
let lastPart = parts[parts.length - 1];
return `${lastPart} 구역 자리 #${idx + 1}`;
}

10
start_docker_wsl.bat Normal file
View File

@@ -0,0 +1,10 @@
@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%
)

107
start_docker_wsl.ps1 Normal file
View File

@@ -0,0 +1,107 @@
[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,6 +1,49 @@
# HM ITAM Server Start Script
[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 " HM ITAM System Start" -ForegroundColor Cyan
Write-Host "============================================" -ForegroundColor Cyan
@@ -21,6 +64,13 @@ if (-not (Test-Path "node_modules")) {
Write-Host "[INFO] Checking ports..."
$backendPort = 3000
$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) {
Write-Host "[WARNING] Port $backendPort [Backend] is already in use." -ForegroundColor Yellow
@@ -30,6 +80,21 @@ if (Get-NetTCPConnection -LocalPort $frontendPort -ErrorAction SilentlyContinue)
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 "[INFO] Starting Backend [Port: 3000]..."
Start-Process cmd -ArgumentList "/k npm run server"

4
stop_docker_wsl.bat Normal file
View File

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

13
stop_docker_wsl.ps1 Normal file
View File

@@ -0,0 +1,13 @@
[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

Binary file not shown.

View File

@@ -1,12 +1,15 @@
import { defineConfig } from 'vite';
import { defineConfig, loadEnv } from 'vite';
import { resolve } from 'path';
const proxyTarget = process.env.VITE_DEV_PROXY_TARGET || 'http://localhost:3000';
const env = loadEnv('', process.cwd(), '');
const backendPort = env.PORT || '3001';
const proxyTarget = process.env.VITE_DEV_PROXY_TARGET || `http://localhost:${backendPort}`;
export default defineConfig({
server: {
port: 8080,
host: true, // Listen on all local IPs
allowedHosts: true,
proxy: {
'/api': {
target: proxyTarget,
@@ -23,6 +26,7 @@ export default defineConfig({
input: {
main: resolve(__dirname, 'index.html'),
map_editor: resolve(__dirname, 'map_editor.html'),
mobile: resolve(__dirname, 'mobile.html'),
}
}
}