Revert "feat(uiws): UIWS 업무모듈(업무일지·일정·쪽지·통계)+2FA 이식"

This reverts commit 707416f78c.
This commit is contained in:
DESKTOP-TKLFCPR\ython 2026-06-20 21:23:03 +09:00
parent 707416f78c
commit 8e0481e840
77 changed files with 59 additions and 8927 deletions

View File

@ -1,54 +0,0 @@
---
name: esn-backend-dev
description: GUARDiA ESN 백엔드 구현 에이전트. Spring Boot 3.5 Java 17 + MyBatis + PostgreSQL(esn_db) 패턴으로 ESL(전자 가격표) 통합 관리 도메인(테넌트·매장·템플릿·POS변환·알람·HCore·펌웨어·작업이력·상품·업데이트큐)을 com.zioinfo.esn 패키지에 구현한다. Ollama 온프레미스 AI(알람 원인 분석·POS 데이터 자동 분류)와 ITSM 연계 담당.
metadata:
type: agent
tools: [Read, Write, Edit, Bash, Glob, Grep]
---
# ESN 백엔드 개발 에이전트
## 역할
`C:\GUARDiA\workspace\guardia-esn\backend\` 에서 Spring Boot 3.5 + Java 17 + MyBatis + PostgreSQL ESN 플랫폼 백엔드를 개발·유지한다.
## 기술 스택
- Spring Boot 3.5 / Java 17
- MyBatis 3.0.3 + PostgreSQL(esn_db/esn_user)
- JWT(jjwt 0.12.6) + Spring Security
- Ollama 온프레미스(폴백 필수) + ITSM 연계
## 불변 규칙
1. `@MapperScan(annotationClass = Mapper.class)` — basePackages 절대 금지
2. Hikari `maximum-pool-size: 3` — 공유 PG 서버
3. Ollama 호출 실패 시 fallback 응답 반환 (예외 던지지 않음)
4. API 응답에서 `passwordHash`, 자격증명 필드 완전 제외
5. AES-256-GCM 자격증명 암호화 저장
6. admin 시드: bcrypt `$2a$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LnCWh7Vz.Xm` (admin/admin123)
## 도메인 구조
```
com.zioinfo.esn/
├── EsnApplication.java (@MapperScan(annotationClass=Mapper.class))
├── auth/ (JwtUtil, JwtFilter, AuthController, AuthService, EsnUser)
├── common/ (ApiResponse)
├── config/ (SecurityConfig, WebConfig, CryptoUtil, OllamaClient)
├── domain/ (VO 클래스: TenantVo, StoreVo, TemplateVo, AlarmVo...)
├── mapper/ (@Mapper 인터페이스: TenantMapper, StoreMapper...)
├── service/ (TenantService, StoreService, AlarmService...)
└── controller/ (TenantController, StoreController, AlarmController...)
```
## 멀티테넌트 핵심
모든 데이터는 `tenant_code VARCHAR(20)` 컬럼으로 격리:
- LGINNOTEK — LG 이노텍
- LGIT — LG IT
- EMART — 이마트
- ZIOINFO — 지오정보기술 (기본)
## 엔드포인트 패턴
- `GET /api/{도메인}s` — 목록 (tenantCode 파라미터)
- `POST /api/{도메인}s` — 생성
- `PUT /api/{도메인}s/{id}` — 수정
- `DELETE /api/{도메인}s/{id}` — 삭제
- `GET /api/ai/alarm-analysis` — Ollama AI 알람 분석
- `POST /api/ai/pos-classify` — POS 데이터 자동 분류

View File

@ -1,39 +0,0 @@
---
name: esn-devops-dev
description: GUARDiA ESN 인프라·배포·CI/CD 에이전트. Gitea 저장소(zio/guardia-esn) 관리, deploy_server.py guardia-esn 블록(포트 8016) 추가, Jenkinsfile·setup 스크립트 작성, systemd 서비스 관리, DB 초기화, 홈페이지 솔루션 페이지 연동.
metadata:
type: agent
tools: [Read, Write, Edit, Bash, Glob, Grep]
---
# ESN DevOps 에이전트
## 역할
`C:\GUARDiA\workspace\guardia-esn\` 프로젝트의 배포·인프라·CI/CD 전체를 담당한다.
## 서버 정보
- 서버: `101.79.17.164` root / `1q2w3e!Q`
- Gitea: `http://127.0.0.1:9003` (내부), `https://git.zioinfo.co.kr` (외부)
- Gitea 계정: `zio` / `1q2w3e!Q`
- 포트: backend **8016**, frontend 빌드 → 단일 JAR
## DB 정보
- DB 이름: `guardia_esn_db`
- DB 사용자: `guardia_esn_user`
- DB 비밀번호: `guardia_esn_pass2026`
- 참고: `esn_db`(esn_user)는 기존 zioinfo-esn용 DB — 충돌 방지를 위해 별도 DB 사용
## 배포 절차
1. repos에 `fresh git init` (모노레포 .git 상속 금지)
2. 프론트 빌드: `npm run build` → static/ 복사
3. 백엔드 빌드: `mvn clean package -DskipTests`
4. JAR → `/opt/guardia-esn/app.jar`
5. systemd `guardia-esn.service``spring.sql.init.mode=never`
6. Gitea 저장소 생성 → push
## 함정 목록
- repos에 fresh `git init` 필수 (모노레포 bundle 1.5GB 방지)
- deploy_server.py 수정 후 zioinfo-deploy 서비스 재시작 필수
- Gitea webhook secret: `zioinfo-deploy-2026`
- HikariCP `maximum-pool-size=3` 반드시 유지
- DB는 앱 사용자 소유로 생성 후 schema.sql 적용

View File

@ -1,42 +0,0 @@
---
name: esn-frontend-dev
description: GUARDiA ESN 프론트엔드 구현 에이전트. React 19 + Vite + TypeScript + Tailwind CSS로 ESL 통합 관리자 웹(대시보드·테넌트·매장·템플릿·POS변환·알람·HCore·펌웨어·작업이력·상품·업데이트큐·AI분석·사용자관리)을 구현한다.
metadata:
type: agent
tools: [Read, Write, Edit, Bash, Glob, Grep]
---
# ESN 프론트엔드 개발 에이전트
## 역할
`C:\GUARDiA\workspace\guardia-esn\frontend\` 에서 React 19 + Vite + TypeScript + Tailwind CSS ESN 관리자 웹을 개발·유지한다.
## 기술 스택
- React 19 + Vite + TypeScript
- Tailwind CSS (다크 테마)
- Recharts (통계 차트)
- React Router v6
## 페이지 목록 (src/pages/)
| 파일 | 경로 | 설명 |
|------|------|------|
| Dashboard.tsx | / | 전체 현황 차트 |
| TenantAdmin.tsx | /tenants | 테넌트 관리 |
| StoreList.tsx | /stores | 매장 관리 |
| TemplateList.tsx | /templates | ESL 템플릿 |
| PosCvtList.tsx | /pos-cvt | POS 변환 데이터 |
| ProductList.tsx | /products | 상품/가격 |
| AlarmList.tsx | /alarms | 알람 모니터링 |
| HCoreStatus.tsx | /hcore | HCore 게이트웨이 |
| FirmwareList.tsx | /firmware | 펌웨어 관리 |
| WorkHistory.tsx | /work | 작업 이력 |
| TagBindingList.tsx | /tags | 태그 바인딩 |
| UpdateQueueList.tsx | /queue | 업데이트 큐 |
| UserList.tsx | /users | 사용자 관리 |
| AiAnalysis.tsx | /ai | AI 분석 |
| Login.tsx | /login | 로그인 |
## 빌드 원칙
- Vite build → `backend/src/main/resources/static/` 복사 → `mvn package` (단일 JAR)
- `vite.config.ts` outDir: `../backend/src/main/resources/static`
- API 호출: `src/api/client.ts` (axios 기반, JWT Bearer 헤더)

View File

@ -1,29 +0,0 @@
---
name: esn-qa
description: GUARDiA ESN QA 에이전트. 백엔드 API 응답 shape와 프론트엔드 호출을 대조 검증한다. 테넌트 격리, 알람 이력, HCore 상태, 펌웨어 배포 경계면의 필드 불일치·인증 흐름·민감정보(SSH·자격증명) 노출·RBAC 가드 누락·AI 온프레미스 준수를 검증한다.
metadata:
type: agent
tools: [Read, Write, Edit, Bash, Glob, Grep]
---
# ESN QA 에이전트
## 검증 항목
1. **테넌트 격리** — tenant_code 필터 누락 시 크로스 테넌트 데이터 노출 여부
2. **인증 흐름** — JWT 만료·무효 토큰 시 401 반환 확인
3. **민감정보 제외** — API 응답에 `passwordHash`, SSH 자격증명 필드 미포함
4. **AI 온프레미스** — Ollama 외 외부 AI API 호출 없음
5. **알람 통계** — Dashboard 집계 쿼리 정합성
6. **HCore 상태** — 연결/비연결 상태 전이 정확성
## 테스트 방법
```bash
# 서버에서 ESN API 테스트
curl -X POST http://localhost:8016/api/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"admin123"}'
# 테넌트 격리 검증
curl -H 'Authorization: Bearer {token}' \
'http://localhost:8016/api/stores?tenantCode=LGINNOTEK'
```

View File

@ -1,118 +0,0 @@
# guardia-esn-orchestrator
## 역할
레거시 ESL(전자 가격표) 관리 시스템(`workspace/esn/` 6개 프로젝트 — Spring Boot 1.5 + Java 8 + Thymeleaf)을 Spring Boot 3.5 + Java 17 + React 19 + PostgreSQL 단일 플랫폼으로 통합한 `workspace/guardia-esn/` 프로젝트를 운영·확장하는 오케스트레이터 스킬.
## 트리거
"guardia-esn", "ESN 통합", "ESL 플랫폼", "전자가격표", "HCore", "POS 변환", "PosCvt", "ESN 알람", "ESL 템플릿", "업데이트 큐", "다시 실행", "업데이트" 요청 시 이 스킬을 사용하라.
## 프로젝트 정보
- **위치:** `C:\GUARDiA\workspace\guardia-esn\`
- **포트:** backend:8016 (단일 JAR, Vite 번들)
- **DB:** `guardia_esn_db` / `guardia_esn_user` / `guardia_esn_pass2026`
- **패키지:** `com.zioinfo.esn`
- **멀티테넌트:** `tenant_code VARCHAR(20)` — LGINNOTEK / LGIT / EMART / ZIOINFO
- **Gitea:** `http://git.zioinfo.co.kr/zio/guardia-esn`
- **서버:** `101.79.17.164` `/opt/guardia-esn/`
## 레거시 소스 (C:\GUARDiA\workspace\esn\)
| 레거시 프로젝트 | 용도 | 현대화 대상 |
|----------------|------|------------|
| ESN_WEB_ZIOINFO | ZIOINFO ESL 웹 관리 (Thymeleaf) | → React 19 관리자 웹 |
| ESN_WEB_LGInnotek | LG Innotek ESL 웹 (Thymeleaf) | → 멀티테넌트 LGINNOTEK |
| ESN_DAEMON_EMART | 이마트 ESL 데몬 | → EMART 테넌트 |
| ESN_DAEMON_LGIT | LG IT ESL 데몬 | → LGIT 테넌트 |
| esn | ESN 코어 (Maven) | → 도메인 모델 |
| ESL_AUTO_UPDATE | 펌웨어 자동 업데이트 | → firmware 도메인 |
| restapi_lgit | LGIT REST API | → API 통합 |
| UCS_LGU | UCS 시스템 | → HCore 통합 |
## 아키텍처
```
레거시 ESL 8개 프로젝트 (Spring Boot 1.5 + Java 8 + Thymeleaf)
↓ 현대화
guardia-esn (Spring Boot 3.5 + Java 17 + React 19 + PostgreSQL)
backend:8016 ←→ frontend static bundle (단일 JAR)
ITSM:9001 · Ollama:11434
```
## 도메인 개념
| 도메인 | 테이블 | 설명 |
|--------|--------|------|
| Tenant | esn_tenants | 멀티테넌트 — LGINNOTEK·LGIT·EMART·ZIOINFO |
| StoreGroup | esn_store_groups | 매장 그룹 |
| Store | esn_stores | 매장 (tenantCode 격리) |
| Template | esn_templates | ESL 표시 템플릿 |
| PosCvt | esn_pos_cvt | POS→ESL 가격 변환 데이터 |
| Alarm / AlarmHistory | esn_alarms | 장치 알람·이력 |
| HCore | esn_hcores | 게이트웨이/허브 장치 |
| WorkHistory | esn_work_history | 작업 이력 |
| Firmware | esn_firmware | 장치 펌웨어 |
| User | esn_users | 사용자 (멀티테넌트 RBAC) |
| Product | esn_products | 상품 가격 |
| TagBinding | esn_tag_bindings | ESL 태그↔상품 바인딩 |
| UpdateQueue | esn_update_queue | 업데이트 큐 |
## 에이전트 팀
| 에이전트 | 역할 |
|----------|------|
| `esn-backend-dev` | Spring Boot 3.5 백엔드 — 도메인·매퍼·서비스·컨트롤러 |
| `esn-frontend-dev` | React 19 + Vite + TypeScript — 관리자 웹 |
| `esn-devops-dev` | Gitea·deploy·Jenkinsfile·setup·홈페이지 |
| `esn-qa` | API 통합 테스트 + 크로스 경계면 검증 |
## 구현 원칙 (GUARDiA 불변 규칙)
1. `@MapperScan(annotationClass = Mapper.class)` — basePackages 아님
2. Hikari `maximum-pool-size=3` — 공유 PG 서버
3. repos에 `fresh git init` — 모노레포 .git 상속 금지
4. 단일 JAR: 프론트 빌드 → `backend/src/main/resources/static/` → mvn package
5. Ollama 폴백: 연결 실패 시 기본 응답 반환 (예외 던지지 않음)
6. admin 시드: bcrypt `$2a$12$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LnCWh7Vz.Xm` (admin/admin123)
7. API 응답에 `password_hash`, 자격증명 필드 절대 미포함
8. AES-256-GCM 자격증명 암호화 저장
9. SecurityConfig에 static 리소스 `permitAll` 필수 (SPA 403 방지)
## 파일 구조
```
workspace/guardia-esn/
├── .claude/
│ ├── agents/
│ │ ├── esn-backend-dev.md
│ │ ├── esn-frontend-dev.md
│ │ ├── esn-devops-dev.md
│ │ └── esn-qa.md
│ └── skills/guardia-esn-orchestrator/SKILL.md
├── CLAUDE.md
├── Jenkinsfile
├── backend/
│ ├── pom.xml
│ └── src/main/
│ ├── java/com/zioinfo/esn/
│ │ ├── EsnApplication.java (@MapperScan(annotationClass=Mapper.class))
│ │ ├── auth/
│ │ ├── common/
│ │ ├── config/ (SecurityConfig, WebConfig, CryptoUtil, OllamaClient)
│ │ ├── domain/ (VO 클래스)
│ │ ├── mapper/ (@Mapper 인터페이스)
│ │ ├── service/
│ │ └── controller/
│ └── resources/
│ ├── application.yml
│ ├── mapper/*.xml
│ ├── db/schema.sql
│ └── static/ (Vite 빌드 산출물)
└── frontend/
├── package.json
├── vite.config.ts
└── src/
├── App.tsx
├── api/client.ts
├── components/
└── pages/ (15개 관리자 화면)
```
## 변경 이력
| 날짜 | 내용 | 사유 |
|------|------|------|
| 2026-06-16 | 초기 하네스 구성 + 통합 소스 생성 | 레거시 ESN 8개 프로젝트 → 단일 현대화 플랫폼 |

121
CLAUDE.md
View File

@ -1,121 +0,0 @@
# GUARDiA ESN — ESL 통합 관리 플랫폼
> 레거시 ESL(전자 가격표) 8개 Spring Boot 1.5 프로젝트를 단일 현대화 플랫폼으로 통합한 프로젝트
---
## 프로젝트 개요
`C:\GUARDiA\workspace\esn\` 의 레거시 ESN 시스템(ESN_WEB_ZIOINFO·ESN_WEB_LGInnotek·ESN_DAEMON_EMART·ESN_DAEMON_LGIT·esn·ESL_AUTO_UPDATE·restapi_lgit·UCS_LGU)을
**Spring Boot 3.5 + Java 17 + React 19 + PostgreSQL** 단일 플랫폼으로 재구현.
멀티테넌트 (TENANT_A / TENANT_B / EMART / ZIOINFO), Ollama 온프레미스 AI 알람 분석·POS 자동 분류.
---
## 기술 스택
| 레이어 | 기술 |
|--------|------|
| Backend | Spring Boot 3.5 · Java 17 · MyBatis 3.0.3 |
| Database | PostgreSQL (`guardia_esn_db` / `guardia_esn_user`) |
| Frontend | React 19 · Vite · TypeScript · Tailwind CSS |
| AI | Ollama 온프레미스 (llama3, llava) |
| 인증 | Spring Security · JWT (jjwt 0.12.6) |
| 배포 | 단일 JAR (Vite 번들 → static/) |
---
## 실행 명령어
```bash
# 프론트엔드 빌드 (필수 — 백엔드 static/ 번들)
cd frontend && npm install && npm run build
# 백엔드 빌드 + 실행
cd backend && mvn clean package -DskipTests
java -jar target/zioinfo-esn-1.0.0.jar \
--spring.datasource.url=jdbc:postgresql://localhost:5432/guardia_esn_db \
--spring.datasource.username=guardia_esn_user \
--spring.datasource.password=guardia_esn_pass2026 \
--server.port=8016
```
---
## 환경변수
| 변수 | 기본값 | 설명 |
|------|--------|------|
| DB_URL | jdbc:postgresql://localhost:5432/esn_db | 운영에서는 guardia_esn_db |
| DB_USER | esn_user | 운영에서는 guardia_esn_user |
| DB_PASS | esn_pass2026 | 운영에서는 guardia_esn_pass2026 |
| OLLAMA_URL | http://localhost:11434 | Ollama 서버 |
| JWT_SECRET | ZioInfoEsnJwtSecret... | JWT 서명 키 |
---
## 디렉터리 구조
```
workspace/guardia-esn/
├── .claude/
│ ├── agents/ (에이전트 4종)
│ └── skills/guardia-esn-orchestrator/SKILL.md
├── CLAUDE.md ← 이 파일
├── Jenkinsfile
├── backend/
│ ├── pom.xml
│ └── src/main/
│ ├── java/com/zioinfo/esn/ (67개 Java 파일)
│ └── resources/
│ ├── application.yml
│ ├── db/schema.sql (13개 테이블)
│ └── mapper/*.xml (15개 MyBatis XML)
└── frontend/
└── src/
├── App.tsx
├── api/client.ts
├── components/ (Layout, Sidebar, Header...)
└── pages/ (15개 관리자 화면)
```
---
## 도메인 (13개)
| 도메인 | 테이블 | 설명 |
|--------|--------|------|
| Tenant | esn_tenants | 멀티테넌트 (LGINNOTEK·LGIT·EMART·ZIOINFO) |
| StoreGroup | esn_store_groups | 매장 그룹 |
| Store | esn_stores | 매장 (tenantCode 격리) |
| Template | esn_templates | ESL 표시 템플릿 |
| PosCvt | esn_pos_cvt | POS→ESL 가격 변환 |
| Alarm | esn_alarms | 장치 알람 |
| HCore | esn_hcores | 게이트웨이/허브 |
| WorkHistory | esn_work_history | 작업 이력 |
| Firmware | esn_firmware | 펌웨어 버전 |
| User | esn_users | 사용자 (RBAC) |
| Product | esn_products | 상품/가격 |
| TagBinding | esn_tag_bindings | ESL↔상품 바인딩 |
| UpdateQueue | esn_update_queue | 업데이트 큐 |
---
## 보안 제약 (불변)
1. `@MapperScan(annotationClass = Mapper.class)` — basePackages 절대 금지
2. Hikari `maximum-pool-size=3` — 공유 PG 서버 보호
3. 외부 AI API 절대 금지 — Ollama localhost:11434만 허용
4. API 응답에서 `passwordHash`, `ssh_*` 필드 완전 제외
5. SecurityConfig에 static 리소스 `permitAll` 필수 (SPA 403 방지)
---
## 하네스 트리거
`guardia-esn-orchestrator` 스킬을 사용하라:
- ESN 기능 추가·수정
- 배포 작업
- 레거시 소스 분석·통합
- 알람·POS·HCore 관련 작업

23
Jenkinsfile vendored
View File

@ -2,10 +2,10 @@ pipeline {
agent any agent any
environment { environment {
APP_NAME = 'guardia-esn' APP_NAME = 'zioinfo-esn'
DEPLOY_DIR = '/opt/guardia-esn' DEPLOY_DIR = '/opt/zioinfo-esn/src'
SERVICE = 'guardia-esn' SERVICE = 'zioinfo-esn'
JAR_NAME = 'zioinfo-esn-1.0.0.jar' JAR_NAME = 'esn.jar'
} }
stages { stages {
@ -18,7 +18,7 @@ pipeline {
stage('Build Frontend') { stage('Build Frontend') {
steps { steps {
dir('frontend') { dir('frontend') {
sh 'npm ci --legacy-peer-deps' sh 'npm ci'
sh 'npm run build' sh 'npm run build'
} }
} }
@ -36,8 +36,8 @@ pipeline {
steps { steps {
sh """ sh """
sudo systemctl stop ${SERVICE} || true sudo systemctl stop ${SERVICE} || true
sudo mkdir -p ${DEPLOY_DIR} sudo mkdir -p ${DEPLOY_DIR}/backend/target
sudo cp backend/target/${JAR_NAME} ${DEPLOY_DIR}/app.jar sudo cp backend/target/${JAR_NAME} ${DEPLOY_DIR}/backend/target/${JAR_NAME}
sudo systemctl start ${SERVICE} sudo systemctl start ${SERVICE}
""" """
} }
@ -46,8 +46,8 @@ pipeline {
stage('Health Check') { stage('Health Check') {
steps { steps {
sh """ sh """
sleep 20 sleep 15
curl -sf http://localhost:8016/actuator/health | grep -q UP || exit 1 curl -sf http://localhost:8015/actuator/health | grep -q UP || exit 1
""" """
} }
} }
@ -55,11 +55,10 @@ pipeline {
post { post {
success { success {
echo "guardia-esn 배포 성공" echo "zioinfo-esn 배포 성공"
} }
failure { failure {
sh "sudo systemctl stop ${SERVICE} || true" echo "zioinfo-esn 배포 실패 — 롤백 필요"
echo "guardia-esn 배포 실패"
} }
} }
} }

View File

@ -1,35 +1,22 @@
package com.zioinfo.esn.auth; package com.zioinfo.esn.auth;
import com.zioinfo.esn.common.ApiResponse; import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.uiws.auth.TwoFactorService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.Map; import java.util.Map;
/**
* ESN 인증 컨트롤러.
* - /login: 2FA off { twofa:"false", token, type }, 2FA on 이면 { twofa:"true", verifyToken, step, maskedEmail }.
* - /verify: (UIWS 2FA 이식) verify-token + 인증코드 access 토큰 발급.
* 기존 클라이언트는 응답에 token 필드가 그대로 존재(2FA off ) 회귀 0.
*/
@RestController @RestController
@RequestMapping("/api/auth") @RequestMapping("/api/auth")
@RequiredArgsConstructor @RequiredArgsConstructor
public class AuthController { public class AuthController {
private final AuthService authService; private final AuthService authService;
private final TwoFactorService twoFactorService;
@PostMapping("/login") @PostMapping("/login")
public ApiResponse<Map<String, String>> login(@RequestBody LoginRequest req) { public ApiResponse<Map<String, String>> login(@RequestBody LoginRequest req) {
return ApiResponse.ok(authService.login(req.username(), req.password())); String token = authService.login(req.username(), req.password());
} return ApiResponse.ok(Map.of("token", token, "type", "Bearer"));
/** UIWS 2FA 이식: 2차 인증 코드 검증 → access 토큰 발급. */
@PostMapping("/verify")
public ApiResponse<Map<String, String>> verify(@RequestBody VerifyRequest req) {
return ApiResponse.ok(twoFactorService.verify(req.verifyToken(), req.code()));
} }
@GetMapping("/me") @GetMapping("/me")
@ -45,6 +32,4 @@ public class AuthController {
} }
record LoginRequest(String username, String password) {} record LoginRequest(String username, String password) {}
record VerifyRequest(String verifyToken, String code) {}
} }

View File

@ -1,21 +1,12 @@
package com.zioinfo.esn.auth; package com.zioinfo.esn.auth;
import com.zioinfo.esn.auth.mapper.UserAuthMapper; import com.zioinfo.esn.auth.mapper.UserAuthMapper;
import com.zioinfo.esn.uiws.auth.TwoFactorService;
import com.zioinfo.esn.uiws.common.UiwsApiException;
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.util.Map; import java.util.Map;
/**
* ESN 인증 서비스.
* - 기존 단일 JWT 로그인 보존(2FA off 또는 미설정 회귀 0).
* - UIWS 2FA 레이어(이메일 코드) 추가: 2FA on 이면 1차 통과 verify-token + 이메일코드 발급.
* 실패 누적 max-login-fail 계정 잠금.
*/
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
public class AuthService { public class AuthService {
@ -23,52 +14,17 @@ public class AuthService {
private final UserAuthMapper userMapper; private final UserAuthMapper userMapper;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil; private final JwtUtil jwtUtil;
private final TwoFactorService twoFactorService;
/** public String login(String username, String password) {
* 1차 로그인. 2FA 활성 verify-token + 이메일코드 흐름으로 분기,
* 비활성 기존처럼 access 토큰 즉시 발급.
*
* @return 2FA off: { token, type, twofa:"false" }
* 2FA on : { verifyToken, step:"EMAIL", maskedEmail, twofa:"true" }
*/
public Map<String, String> login(String username, String password) {
EsnUser user = userMapper.findByUsername(username); EsnUser user = userMapper.findByUsername(username);
// 잠금 우선 차단(존재하는 계정에 한해 잠금 메시지 존재 여부 누설 최소화)
if (user != null && twoFactorService.isLocked(user)) {
throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED);
}
if (user == null || !user.isActive()) { if (user == null || !user.isActive()) {
throw new RuntimeException("ERR-AUTH-001: 존재하지 않거나 비활성 계정"); throw new RuntimeException("ERR-AUTH-001: 존재하지 않거나 비활성 계정");
} }
if (!passwordEncoder.matches(password, user.getPasswordHash())) { if (!passwordEncoder.matches(password, user.getPasswordHash())) {
// 2FA 활성 실패 누적/잠금. 비활성 기존 동작(메시지만) 유지.
if (twoFactorService.isEnabled()) {
twoFactorService.recordLoginFailure(username);
EsnUser after = userMapper.findByUsername(username);
if (after != null && Boolean.TRUE.equals(after.getLocked())) {
throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED);
}
}
throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치"); throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치");
} }
// 비밀번호 검증 통과
if (twoFactorService.isEnabled()) {
Map<String, String> step1 = twoFactorService.beginTwoFactor(user);
return Map.of(
"twofa", "true",
"verifyToken", step1.get("verifyToken"),
"step", step1.get("step"),
"maskedEmail", step1.getOrDefault("maskedEmail", ""));
}
// 2FA 비활성 기존 단일 로그인 흐름(회귀 0)
userMapper.resetLoginFail(username);
userMapper.updateLastLogin(username); userMapper.updateLastLogin(username);
String token = jwtUtil.generate(username, user.getRole(), user.getTenantCode()); return jwtUtil.generate(username, user.getRole(), user.getTenantCode());
return Map.of("twofa", "false", "token", token, "type", "Bearer");
} }
public Map<String, String> me(String token) { public Map<String, String> me(String token) {

View File

@ -18,19 +18,4 @@ public class EsnUser {
private boolean active; private boolean active;
private LocalDateTime lastLoginAt; private LocalDateTime lastLoginAt;
private LocalDateTime createdAt; private LocalDateTime createdAt;
// UIWS 2FA 이식 컬럼 (esn_user ALTER, db/91_uiws_port.sql)
/** 이메일 인증코드(6자리). 발급 후 verify 단계에서 검증. API 응답에는 절대 미포함. */
@JsonIgnore
private String emailVerifyCode;
/** 인증코드 만료시각. */
@JsonIgnore
private LocalDateTime emailVerifyExpire;
/** 로그인 실패 누적 횟수(기본 0). max-login-fail 도달 시 locked. */
private Integer loginFailCount;
/** 계정 잠금 여부(기본 false). */
private Boolean locked;
/** TOTP 시크릿(UIWS OTP 경로 대비, 현재 이메일 흐름에서는 미사용). */
@JsonIgnore
private String otpSecret;
} }

View File

@ -26,10 +26,7 @@ public class JwtFilter extends OncePerRequestFilter {
String header = req.getHeader("Authorization"); String header = req.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) { if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7); String token = header.substring(7);
// 보안(치명 UIWS 2FA): purpose=2fa verify-token access 토큰이 아니다. if (jwtUtil.isValid(token)) {
// 동일 서명키라 isValid() 통과하므로 별도 차단하지 않으면 2차 인증 보호 API 접근(2FA 완전 우회) 가능.
// verify-token 인증 컨텍스트를 세우지 않고 무시한다(/api/auth/verify 에서만 사용).
if (jwtUtil.isValid(token) && !jwtUtil.isVerifyToken(token)) {
String username = jwtUtil.getUsername(token); String username = jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token); String role = jwtUtil.getRole(token);
var auth = new UsernamePasswordAuthenticationToken( var auth = new UsernamePasswordAuthenticationToken(

View File

@ -35,48 +35,6 @@ public class JwtUtil {
.compact(); .compact();
} }
/**
* UIWS 2FA 이식: 1차 로그인 통과 발급하는 단기 verify-token.
* purpose=2fa 클레임으로 access 토큰과 구분(verify-token 으로는 보호 API 접근 불가 JwtFilter 에서 차단).
*/
public String generateVerifyToken(String username, long validitySeconds) {
return Jwts.builder()
.subject(username)
.claim("purpose", "2fa")
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + validitySeconds * 1000L))
.signWith(key())
.compact();
}
/** verify-token 검증 후 username 반환. 유효하지 않거나 purpose!=2fa 면 null. */
public String parseVerifyTokenUsername(String token) {
try {
Claims c = parse(token);
if (!"2fa".equals(c.get("purpose", String.class))) {
return null;
}
return c.getSubject();
} catch (JwtException | IllegalArgumentException e) {
log.debug("verify-token 검증 실패: {}", e.getMessage());
return null;
}
}
/**
* 보안(치명): 토큰이 2FA verify-token(purpose=2fa)인지 판별.
* verify-token access 같은 서명키라 isValid() 통과하므로, JwtFilter 별도로
* 걸러내지 않으면 2차 코드 검증 없이 보호 API 접근(2FA 완전 우회) 된다.
* JwtFilter purpose=2fa 토큰을 인증 컨텍스트로 세우지 않는다(/api/auth/verify 에서만 사용).
*/
public boolean isVerifyToken(String token) {
try {
return "2fa".equals(parse(token).get("purpose", String.class));
} catch (JwtException | IllegalArgumentException e) {
return false;
}
}
public Claims parse(String token) { public Claims parse(String token) {
return Jwts.parser().verifyWith(key()).build() return Jwts.parser().verifyWith(key()).build()
.parseSignedClaims(token).getPayload(); .parseSignedClaims(token).getPayload();

View File

@ -4,29 +4,8 @@ import com.zioinfo.esn.auth.EsnUser;
import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
@Mapper @Mapper
public interface UserAuthMapper { public interface UserAuthMapper {
EsnUser findByUsername(@Param("username") String username); EsnUser findByUsername(@Param("username") String username);
int updateLastLogin(@Param("username") String username); int updateLastLogin(@Param("username") String username);
// UIWS 2FA 이식: 실패 카운트/잠금/인증코드 갱신 (멱등 UPDATE)
/** 로그인 성공 시 실패 카운트 초기화. */
int resetLoginFail(@Param("username") String username);
/** 로그인 실패 누적(+1) 및 임계 도달 시 잠금. */
int incrementLoginFail(@Param("username") String username, @Param("maxFail") int maxFail);
/** 1차 통과 시 이메일 인증코드/만료 저장(verify 단계에서 검증). */
int saveEmailCode(@Param("username") String username,
@Param("code") String code,
@Param("expire") LocalDateTime expire);
/** 2차 검증 성공 시 코드 폐기. */
int clearEmailCode(@Param("username") String username);
/** 관리자 잠금 해제(실패 카운트/잠금 초기화). */
int unlock(@Param("username") String username);
} }

View File

@ -1,64 +0,0 @@
package com.zioinfo.esn.common;
import com.zioinfo.esn.uiws.common.UiwsApiException;
import jakarta.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
/**
* 전역 예외 처리(보안 불변규칙: 스택트레이스/SQL/자격증명 미노출).
*
* <p>UIWS 이식 모듈 + 2FA 레이어 도입과 함께 추가. 기존 ESN 컨트롤러도 동일 정책으로 보호한다
* (예외 메시지는 사용자 노출용 요약만 내부 원인은 서버 로그에만 남긴다).
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/** UIWS 이식/2FA 도메인 예외 → 400 + 안전한 사용자 메시지. */
@ExceptionHandler(UiwsApiException.class)
public ResponseEntity<ApiResponse<Void>> handleUiws(UiwsApiException e) {
log.debug("[UIWS] {}: {}", e.getErrorCode().getCode(), e.getMessage());
return ResponseEntity.badRequest().body(ApiResponse.fail(e.getMessage()));
}
/** 요청 검증 실패 → 400 + 필드 요약(스택트레이스 없음). */
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ApiResponse<Void>> handleValidation(MethodArgumentNotValidException e) {
String msg = e.getBindingResult().getFieldErrors().stream()
.findFirst()
.map(fe -> fe.getDefaultMessage())
.orElse("요청 값이 올바르지 않습니다.");
return ResponseEntity.badRequest().body(ApiResponse.fail(msg));
}
/** 권한 거부 → 403 (RBAC 가드). */
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiResponse<Void>> handleDenied(AccessDeniedException e) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(ApiResponse.fail("접근 권한이 없습니다."));
}
/**
* 모든 예외 400 + 요약 메시지(기존 ESN RuntimeException("ERR-AUTH-...") 흐름 보존).
* 스택트레이스/원인은 서버 로그에만 기록하고 응답에는 노출하지 않는다(보안 불변규칙).
*/
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ApiResponse<Void>> handleRuntime(RuntimeException e, HttpServletRequest req) {
log.warn("[ERR] {} {} — {}", req.getMethod(), req.getRequestURI(), e.getMessage());
String msg = e.getMessage() != null ? e.getMessage() : "요청 처리 중 오류가 발생했습니다.";
return ResponseEntity.badRequest().body(ApiResponse.fail(msg));
}
/** 그 외 체크 예외/오류 → 500 + 요약(상세 미노출). */
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleOther(Exception e, HttpServletRequest req) {
log.error("[ERR-500] {} {}", req.getMethod(), req.getRequestURI(), e);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.fail("서버 처리 중 오류가 발생했습니다."));
}
}

View File

@ -51,7 +51,7 @@ public class OllamaClient {
.uri(URI.create(baseUrl + "/api/chat")) .uri(URI.create(baseUrl + "/api/chat"))
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json)) .POST(HttpRequest.BodyPublishers.ofString(json))
.timeout(Duration.ofSeconds(120)) .timeout(Duration.ofSeconds(30))
.build(); .build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString()); HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() == 200) { if (resp.statusCode() == 200) {

View File

@ -1,26 +0,0 @@
package com.zioinfo.esn.uiws.auth;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Update;
/**
* 2FA 로그인 검증 이력 매퍼 (tb_uiws_login_verify). 발급/검증 감사 기록.
* verify_code 감사 이력에만 남고 API 응답에는 절대 노출하지 않는다.
*/
@Mapper
public interface LoginVerifyMapper {
int insert(UiwsLoginVerify v);
/** 동일 user 의 미검증 EMAIL 이력 중 최신 1건을 검증완료(Y) 처리. */
@Update("""
UPDATE tb_uiws_login_verify
SET verified_yn = 'Y', updated_by = #{userId}, updated_at = now()
WHERE verify_id = (
SELECT verify_id FROM tb_uiws_login_verify
WHERE user_id = #{userId} AND verify_method = 'EMAIL' AND verified_yn = 'N'
ORDER BY verify_id DESC LIMIT 1
)
""")
int markLatestVerified(String userId);
}

View File

@ -1,161 +0,0 @@
package com.zioinfo.esn.uiws.auth;
import com.zioinfo.esn.auth.EsnUser;
import com.zioinfo.esn.auth.JwtUtil;
import com.zioinfo.esn.auth.mapper.UserAuthMapper;
import com.zioinfo.esn.uiws.common.UiwsApiException;
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
import com.zioinfo.esn.uiws.common.mail.MailSender;
import com.zioinfo.esn.uiws.config.UiwsProperties;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.util.Map;
/**
* UIWS 2FA(이메일 코드) 레이어. ESN 기존 단일 로그인을 보존하면서 2단계 인증을 추가한다.
*
* 흐름:
* 1) 1차 로그인 성공 {@link #beginTwoFactor}: verify-token 발급 + 이메일 인증코드 발송(LogMailSender 폴백) + 감사 기록.
* 2) {@code POST /api/auth/verify}(verifyToken + code) {@link #verify}: 코드 검증 access 발급.
* 3) 로그인 실패 누적 max-login-fail 계정 잠금({@link #recordLoginFailure}).
*
* 보안:
* - 인증코드는 메일/감사 채널로만 전달. API 응답·로그 메시지에 코드/비밀번호/자격증명 절대 미노출(불변규칙).
* - 외부 API 호출 없음(MailSender 폴백은 로그). Ollama 외부 통신 금지 준수.
* - access 토큰은 ESN JwtUtil(username/role/tenantCode 3-클레임) 정책을 그대로 재사용한다.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class TwoFactorService {
private static final SecureRandom RANDOM = new SecureRandom();
private static final String SYSTEM = "SYSTEM";
private final UserAuthMapper userMapper;
private final LoginVerifyMapper loginVerifyMapper;
private final JwtUtil jwtUtil;
private final MailSender mailSender;
private final UiwsProperties properties;
public boolean isEnabled() {
return properties.getAuth().isTwofaEnabled();
}
/** 잠금 여부(1차 로그인 전 차단용). */
public boolean isLocked(EsnUser user) {
return Boolean.TRUE.equals(user.getLocked());
}
/**
* 1차 로그인 성공 2차 인증 시작: verify-token 발급 + 이메일코드 발송 + 감사 기록 + 실패카운트 초기화.
* @return { verifyToken, step:"EMAIL", maskedEmail }
*/
@Transactional
public Map<String, String> beginTwoFactor(EsnUser user) {
long codeValidity = properties.getAuth().getEmailCodeValiditySeconds();
long tokenValidity = properties.getAuth().getVerifyTokenValiditySeconds();
String code = String.format("%06d", RANDOM.nextInt(1_000_000));
LocalDateTime expire = LocalDateTime.now().plusSeconds(codeValidity);
// user 테이블에 코드/만료 저장(실패카운트 초기화) + 감사 이력 기록
userMapper.saveEmailCode(user.getUsername(), code, expire);
recordVerifyAttempt(user.getUsername(), code, expire);
// 이메일 발송(미설정 환경은 LogMailSender 폴백). 코드는 메일 본문에만.
String subject = "[GUARDiA ESN] 로그인 2차 인증 코드";
String body = String.format(
"안녕하세요 %s 님,\n로그인 2차 인증 코드는 [%s] 입니다.\n유효시간: %d초",
user.getUsername(), code, codeValidity);
if (user.getEmail() != null && !user.getEmail().isBlank()) {
mailSender.send(user.getEmail(), subject, body);
} else {
log.warn("[2FA] no email for user={} — code logged only", user.getUsername());
}
String verifyToken = jwtUtil.generateVerifyToken(user.getUsername(), tokenValidity);
// 응답에는 코드 미포함 verifyToken/step/maskedEmail .
return Map.of(
"verifyToken", verifyToken,
"step", "EMAIL",
"maskedEmail", maskEmail(user.getEmail()));
}
/**
* 2차 검증: verify-token + code 검증 access 발급. 코드 폐기 + 감사 이력 검증완료.
* @return { token, type, username, role, tenant }
*/
@Transactional
public Map<String, String> verify(String verifyToken, String code) {
String username = jwtUtil.parseVerifyTokenUsername(verifyToken);
if (username == null) {
throw new UiwsApiException(UiwsErrorCode.VERIFY_TOKEN_INVALID);
}
EsnUser user = userMapper.findByUsername(username);
if (user == null) {
throw new UiwsApiException(UiwsErrorCode.VERIFY_TOKEN_INVALID);
}
if (Boolean.TRUE.equals(user.getLocked())) {
throw new UiwsApiException(UiwsErrorCode.ACCOUNT_LOCKED);
}
boolean codeOk = user.getEmailVerifyCode() != null
&& user.getEmailVerifyCode().equals(code)
&& user.getEmailVerifyExpire() != null
&& user.getEmailVerifyExpire().isAfter(LocalDateTime.now());
if (!codeOk) {
throw new UiwsApiException(UiwsErrorCode.VERIFY_CODE_INVALID);
}
// 코드 폐기 + 감사 검증완료 + 마지막 로그인 갱신
userMapper.clearEmailCode(username);
loginVerifyMapper.markLatestVerified(username);
userMapper.updateLastLogin(username);
String access = jwtUtil.generate(user.getUsername(), user.getRole(), user.getTenantCode());
return Map.of(
"token", access,
"type", "Bearer",
"username", user.getUsername(),
"role", user.getRole() == null ? "" : user.getRole(),
"tenant", user.getTenantCode() == null ? "" : user.getTenantCode());
}
/** 로그인 비밀번호 실패 시 누적/잠금 처리. */
@Transactional
public void recordLoginFailure(String username) {
userMapper.incrementLoginFail(username, properties.getAuth().getMaxLoginFail());
}
private void recordVerifyAttempt(String username, String code, LocalDateTime expire) {
UiwsLoginVerify v = new UiwsLoginVerify();
v.setUserId(username);
v.setVerifyMethod("EMAIL");
v.setVerifyCode(code);
v.setExpireAt(expire);
v.setVerifiedYn("N");
v.setCreatedBy(SYSTEM);
v.setCreatedAt(LocalDateTime.now());
loginVerifyMapper.insert(v);
}
/** 이메일 마스킹(자격증명 보호): ab****@domain. */
private static String maskEmail(String email) {
if (email == null || email.isBlank() || !email.contains("@")) {
return "";
}
int at = email.indexOf('@');
String local = email.substring(0, at);
String domain = email.substring(at);
if (local.length() <= 2) {
return local.charAt(0) + "*" + domain;
}
return local.substring(0, 2) + "*".repeat(Math.max(1, local.length() - 2)) + domain;
}
}

View File

@ -1,19 +0,0 @@
package com.zioinfo.esn.uiws.auth;
import lombok.Data;
import java.time.LocalDateTime;
/** 2FA 로그인 검증 이력 (tb_uiws_login_verify). 원본 com.urp.uiws.domain.LoginVerify 이식. */
@Data
public class UiwsLoginVerify {
private Long verifyId;
private String userId; // ERP username 논리참조
private String verifyMethod; // EMAIL | OTP
private String verifyCode;
private LocalDateTime expireAt;
private String verifiedYn; // Y | N
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}

View File

@ -1,24 +0,0 @@
package com.zioinfo.esn.uiws.common;
import lombok.Getter;
/**
* UIWS 이식 모듈 업무 예외. RuntimeException 상속하여 ERP 기존 GlobalExceptionHandler
* (RuntimeException 400 + message, 스택트레이스 미노출) 그대로 포착된다.
* 추가 인프라/ 없이 ERP 공통 에러 처리와 정합한다.
*/
@Getter
public class UiwsApiException extends RuntimeException {
private final UiwsErrorCode errorCode;
public UiwsApiException(UiwsErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
public UiwsApiException(UiwsErrorCode errorCode, String detail) {
super(detail);
this.errorCode = errorCode;
}
}

View File

@ -1,48 +0,0 @@
package com.zioinfo.esn.uiws.common;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
/**
* 현재 인증 사용자 식별자(=ERP username) 추출 헬퍼. 감사 컬럼(created_by/updated_by)
* 소유자(writer_id/owner_id/sender_id) 기록에 사용한다.
*
* ERP JwtFilter 인증 principal username(String) 세팅하므로 값을 그대로 사용한다.
* (UIWS 원본의 CurrentUser/UserPrincipal 패턴을 ERP 인증 모델에 맞게 단순화 이식.)
*/
public final class UiwsCurrentUser {
private static final String SYSTEM = "SYSTEM";
private UiwsCurrentUser() {
}
/** 현재 사용자 ID(username). 미인증/시스템 컨텍스트는 "SYSTEM". */
public static String id() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.isAuthenticated() && auth.getPrincipal() instanceof String username
&& !"anonymousUser".equals(username)) {
return username;
}
return SYSTEM;
}
/** ADMIN 권한 보유 여부(데이터 가시범위 판정용 — ADMIN=전체). */
public static boolean isAdmin() {
return hasRole("ADMIN");
}
/** MANAGER 이상(MANAGER/ADMIN) 여부 — 업무일지 댓글 권한 등에 사용. */
public static boolean isManagerOrAbove() {
return hasRole("ADMIN") || hasRole("MANAGER") || hasRole("CFO");
}
private static boolean hasRole(String role) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null) {
return false;
}
return auth.getAuthorities().stream()
.anyMatch(a -> ("ROLE_" + role).equals(a.getAuthority()));
}
}

View File

@ -1,29 +0,0 @@
package com.zioinfo.esn.uiws.common;
import java.util.List;
/**
* 데이터 가시범위(스코프) UIWS DataScopeService silo 단순화 이식.
* 원본은 부서계층(TB_DEPT) 기반 스코프를 산출하나, ERP 이식본은 코어 dept 테이블을 이식하지 않으므로
* 역할만으로 판정한다:
* - ADMIN 전체(all=true)
* - 본인 데이터만(ownerIds = [본인])
* (부서계층 스코프가 필요해지면 ERP tb_department 연계로 확장 후속 트랙.)
*/
public final class UiwsDataScope {
private UiwsDataScope() {
}
/** all=true 면 전체 조회(ownerIds 무시). ownerIds 는 항상 본인 포함(빈 IN 회피). */
public record Scope(boolean all, List<String> ownerIds) {
}
public static Scope current() {
String me = UiwsCurrentUser.id();
if (UiwsCurrentUser.isAdmin()) {
return new Scope(true, List.of(me));
}
return new Scope(false, List.of(me));
}
}

View File

@ -1,48 +0,0 @@
package com.zioinfo.esn.uiws.common;
import lombok.Getter;
/**
* UIWS 이식 모듈 도메인 오류 코드.
* 원본 com.urp.uiws.common.exception.ErrorCode worklog/schedule/message/auth(2FA) 영역 발췌·이식.
* 메시지는 사용자 노출용 스택트레이스/SQL 민감정보는 절대 포함하지 않는다(보안 불변규칙).
*/
@Getter
public enum UiwsErrorCode {
// 공통
INVALID_REQUEST("ERR-UIWS-400", "요청이 올바르지 않습니다."),
FORBIDDEN("ERR-UIWS-403", "접근 권한이 없습니다."),
NOT_FOUND("ERR-UIWS-404", "대상을 찾을 수 없습니다."),
// worklog
WORKLOG_NOT_FOUND("ERR-UIWS-WL-404", "업무일지를 찾을 수 없습니다."),
WORKLOG_TIME_OVERLAP("ERR-UIWS-WL-409", "동일 일지 내 시간대가 중복됩니다."),
WORKLOG_TIME_INVALID("ERR-UIWS-WL-422", "근무 시작/종료 시간이 올바르지 않습니다."),
// schedule
SCHEDULE_NOT_FOUND("ERR-UIWS-SC-404", "일정을 찾을 수 없습니다."),
SCHEDULE_DT_INVALID("ERR-UIWS-SC-422", "일정 시작/종료 일시가 올바르지 않습니다."),
DIARY_NOT_FOUND("ERR-UIWS-DI-404", "일지를 찾을 수 없습니다."),
ATTACH_NOT_FOUND("ERR-UIWS-AT-404", "첨부파일을 찾을 수 없습니다."),
ATTACH_REF_TYPE_INVALID("ERR-UIWS-AT-422", "첨부 대상 유형은 SCHEDULE 또는 DIARY 여야 합니다."),
FILE_EMPTY("ERR-UIWS-FILE-400", "업로드할 파일이 비어 있습니다."),
FILE_STORAGE_ERROR("ERR-UIWS-FILE-500", "파일 저장 중 오류가 발생했습니다."),
// message
MESSAGE_NOT_FOUND("ERR-UIWS-MSG-404", "쪽지를 찾을 수 없습니다."),
MESSAGE_RCV_TYPE_INVALID("ERR-UIWS-MSG-422", "수신구분은 RECV(수신) 또는 REF(참조) 여야 합니다."),
// auth (2FA)
VERIFY_TOKEN_INVALID("ERR-UIWS-2FA-401", "2차 검증 토큰이 유효하지 않거나 만료되었습니다."),
VERIFY_CODE_INVALID("ERR-UIWS-2FA-401C", "인증 코드가 올바르지 않거나 만료되었습니다."),
ACCOUNT_LOCKED("ERR-UIWS-2FA-LOCK", "로그인 실패 횟수 초과로 계정이 잠겼습니다. 관리자에게 문의하세요.");
private final String code;
private final String message;
UiwsErrorCode(String code, String message) {
this.code = code;
this.message = message;
}
}

View File

@ -1,23 +0,0 @@
package com.zioinfo.esn.uiws.common.mail;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.stereotype.Component;
/**
* 로컬/개발/미설정용 메일 발송 폴백. 실제 전송 없이 로그로만 기록한다(외부 호출 0).
* esn.uiws.mail.mode=log (기본) 활성. SMTP 운영 mode=smtp 별도 구현 활성화.
*
* 보안: 본문에 인증코드/임시비밀번호가 포함되므로 로그 레벨은 운영에서 조정.
* (인증코드는 API 응답으로는 절대 반환하지 않는다 메일/로그 채널로만 전달.)
*/
@Slf4j
@Component
@ConditionalOnProperty(name = "esn.uiws.mail.mode", havingValue = "log", matchIfMissing = true)
public class LogMailSender implements MailSender {
@Override
public void send(String to, String subject, String body) {
log.info("[UIWS-MAIL:LOG] to={} subject={}\n{}", to, subject, body);
}
}

View File

@ -1,10 +0,0 @@
package com.zioinfo.esn.uiws.common.mail;
/**
* 메일 발송 추상화(UIWS 이식). 로컬/미설정 환경은 LogMailSender(로그만), 운영은 SMTP 구현으로 교체.
* 외부 API 호출은 하지 않는다(보안 불변규칙 Ollama 외부 호출 금지).
*/
public interface MailSender {
void send(String to, String subject, String body);
}

View File

@ -1,41 +0,0 @@
package com.zioinfo.esn.uiws.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* UIWS 이식 모듈 설정. application.yml esn.uiws.* 바인딩.
* 계획서 2FA 설정키(verify-token-validity / max-login-fail / email-code-validity) +
* 첨부 업로드 디렉터리. 모두 안전 기본값 보유(미설정이어도 동작).
*/
@Getter
@Setter
@Component
@ConfigurationProperties(prefix = "esn.uiws")
public class UiwsProperties {
private final Auth auth = new Auth();
private final Upload upload = new Upload();
@Getter
@Setter
public static class Auth {
/** 2FA 레이어 on/off. 기본 on(켜짐). off 시 기존 단일 JWT 로그인 흐름 유지(회귀 0). */
private boolean twofaEnabled = true;
/** 1차 통과 후 verify-token 유효시간(초). 계획서 300(5분). */
private long verifyTokenValiditySeconds = 300;
/** 이메일 인증코드 유효시간(초). 계획서 300(5분). */
private long emailCodeValiditySeconds = 300;
/** 로그인 실패 누적 N회 시 계정 잠금. 계획서 5. */
private int maxLoginFail = 5;
}
@Getter
@Setter
public static class Upload {
/** 첨부파일 저장 루트. 기본 ./uploads/uiws. */
private String uploadDir = "./uploads/uiws";
}
}

View File

@ -1,75 +0,0 @@
package com.zioinfo.esn.uiws.message.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.uiws.message.dto.MessageDtos.*;
import com.zioinfo.esn.uiws.message.service.MessageService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
/**
* UIWS 이식 쪽지(모듈 03). 인증 필수(/api/messages). 원본 prefix·엔드포인트 보존.
* Spring Data Pageable 의존 회피: page/size 단순 파라미터로 이식(ERP는 spring-data-web 미사용).
*/
@RestController
@RequestMapping("/api/messages")
@RequiredArgsConstructor
public class MessageController {
private final MessageService messageService;
@PostMapping
public ApiResponse<MessageSendResponse> send(@Valid @RequestBody MessageSendDto dto) {
return ApiResponse.ok(messageService.send(dto));
}
@GetMapping("/sent")
public ApiResponse<MessageService.SentPage> sent(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate,
@RequestParam(required = false) String titleKeyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ApiResponse.ok(messageService.listSent(fromDate, toDate, titleKeyword, page, size));
}
@GetMapping("/sent/{id}")
public ApiResponse<SentMessageDetailDto> sentDetail(@PathVariable("id") Long id) {
return ApiResponse.ok(messageService.sentDetail(id));
}
@DeleteMapping("/sent")
public ApiResponse<Void> deleteSent(@Valid @RequestBody MessageIdsRequest req) {
messageService.deleteSent(req.ids());
return ApiResponse.ok(null);
}
@GetMapping("/unread-count")
public ApiResponse<Long> unreadCount() {
return ApiResponse.ok(messageService.unreadCount());
}
@GetMapping("/received")
public ApiResponse<MessageService.ReceivedPage> received(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate,
@RequestParam(required = false) String titleKeyword,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ApiResponse.ok(messageService.listReceived(fromDate, toDate, titleKeyword, page, size));
}
@GetMapping("/received/{id}")
public ApiResponse<ReceivedMessageDetailDto> receivedDetail(@PathVariable("id") Long id) {
return ApiResponse.ok(messageService.receivedDetail(id));
}
@DeleteMapping("/received")
public ApiResponse<Void> deleteReceived(@Valid @RequestBody MessageIdsRequest req) {
messageService.deleteReceived(req.ids());
return ApiResponse.ok(null);
}
}

View File

@ -1,100 +0,0 @@
package com.zioinfo.esn.uiws.message.dto;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import java.util.List;
/**
* 쪽지(모듈 03) 요청/응답 DTO 모음. 원본 com.urp.uiws.message.dto.* 이식(필드·shape 동일).
* 프론트 경계면 계약 유지: API 응답 필드명 그대로 보존.
*/
public final class MessageDtos {
private MessageDtos() {
}
/** 전송/답장 요청. */
public record MessageSendDto(
@NotBlank String title,
@NotBlank String content,
Long refWorklogId,
Long replyToId,
@Valid @NotEmpty(message = "수신자는 1명 이상이어야 합니다.") List<MessageReceiverDto> receivers
) {
}
/** 수신자 1건. rcvType: RECV(수신) | REF(참조). */
public record MessageReceiverDto(
@NotBlank String receiverId,
@NotBlank String rcvType
) {
}
/** 전송 응답. */
public record MessageSendResponse(Long messageId) {
}
/** 보낸쪽지함 행. */
public record SentMessageDto(
Long messageId,
String title,
String receiverSummary,
long openCount,
long totalCount,
String sentAt
) {
}
/** 보낸쪽지 상세(수신자 개봉현황). */
public record SentMessageDetailDto(
Long messageId,
String title,
String content,
Long refWorklogId,
String sentAt,
List<ReceiverStatusDto> receivers,
long totalCount,
long openCount,
long unopenCount
) {
}
/** 수신자별 개봉현황. */
public record ReceiverStatusDto(
String receiverNm,
String rcvType,
String readYn,
String readAt
) {
}
/** 받은쪽지함 행. */
public record ReceivedMessageDto(
Long messageId,
String title,
String senderNm,
String sentAt,
String readYn
) {
}
/** 받은쪽지 상세(조회 시 개봉처리). */
public record ReceivedMessageDetailDto(
Long messageId,
String title,
String content,
String senderNm,
String sentAt,
String receivedAt,
Long refWorklogId
) {
}
/** 다중삭제 본문. */
public record MessageIdsRequest(
@NotEmpty(message = "ids는 필수입니다.") List<Long> ids
) {
}
}

View File

@ -1,78 +0,0 @@
package com.zioinfo.esn.uiws.message.mapper;
import com.zioinfo.esn.uiws.message.model.UiwsMessage;
import com.zioinfo.esn.uiws.message.model.UiwsMessageRcv;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 쪽지 MyBatis 매퍼. 원본 JPA MessageRepository/MessageRcvRepository MyBatis 변환 이식.
* 사용자명은 esn_user(username 라벨) 조인으로 라벨화(코어 user 재사용, 별도 user 테이블 미이식).
*/
@Mapper
public interface MessageMapper {
// message 헤더
int insertMessage(UiwsMessage m);
UiwsMessage findMessageById(@Param("messageId") Long messageId);
boolean existsMessageById(@Param("messageId") Long messageId);
/** 보낸쪽지 페이징(SENDER_DEL_YN='N' 제외). */
List<UiwsMessage> searchSent(@Param("senderId") String senderId,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
@Param("titleKeyword") String titleKeyword,
@Param("limit") int limit,
@Param("offset") int offset);
long countSent(@Param("senderId") String senderId,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
@Param("titleKeyword") String titleKeyword);
/** 보낸쪽지 다중 소프트삭제(본인 것만). */
int softDeleteSent(@Param("senderId") String senderId,
@Param("ids") List<Long> ids,
@Param("actor") String actor);
// 수신자
int insertRcv(UiwsMessageRcv rcv);
List<UiwsMessageRcv> findRcvByMessageId(@Param("messageId") Long messageId);
List<UiwsMessageRcv> findRcvByMessageIds(@Param("ids") List<Long> ids);
UiwsMessageRcv findRcvByMessageAndReceiver(@Param("messageId") Long messageId,
@Param("receiverId") String receiverId);
long countUnread(@Param("receiverId") String receiverId);
/** 받은쪽지 개봉처리(READ_YN='Y', READ_AT). */
int markRead(@Param("rcvId") Long rcvId, @Param("actor") String actor, @Param("readAt") LocalDateTime readAt);
int softDeleteReceived(@Param("receiverId") String receiverId,
@Param("ids") List<Long> ids,
@Param("actor") String actor);
/** 받은쪽지 목록(조인: message + rcv). 행: messageId,title,senderId,sentAt,readYn. */
List<Map<String, Object>> searchReceived(@Param("receiverId") String receiverId,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
@Param("titleKeyword") String titleKeyword,
@Param("limit") int limit,
@Param("offset") int offset);
long countReceived(@Param("receiverId") String receiverId,
@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
@Param("titleKeyword") String titleKeyword);
// 사용자명 라벨(코어 user 재사용)
List<Map<String, String>> findUserNames(@Param("ids") List<String> ids);
}

View File

@ -1,24 +0,0 @@
package com.zioinfo.esn.uiws.message.model;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 쪽지 헤더 (tb_uiws_message). 원본 com.urp.uiws.domain.Message 이식.
* MyBatis map-underscore-to-camel-case 컬럼필드 자동 매핑.
*/
@Data
public class UiwsMessage {
private Long messageId;
private String senderId;
private String title;
private String content;
private Long refWorklogId;
private Long replyToId;
private LocalDateTime sentAt;
private String senderDelYn;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}

View File

@ -1,22 +0,0 @@
package com.zioinfo.esn.uiws.message.model;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 쪽지 수신자 (tb_uiws_message_rcv). 원본 com.urp.uiws.domain.MessageRcv 이식.
*/
@Data
public class UiwsMessageRcv {
private Long rcvId;
private Long messageId;
private String receiverId;
private String rcvType; // RECV | REF
private String readYn; // Y | N
private LocalDateTime readAt;
private String receiverDelYn; // Y | N
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}

View File

@ -1,271 +0,0 @@
package com.zioinfo.esn.uiws.message.service;
import com.zioinfo.esn.uiws.common.UiwsApiException;
import com.zioinfo.esn.uiws.common.UiwsCurrentUser;
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
import com.zioinfo.esn.uiws.message.dto.MessageDtos.*;
import com.zioinfo.esn.uiws.message.mapper.MessageMapper;
import com.zioinfo.esn.uiws.message.model.UiwsMessage;
import com.zioinfo.esn.uiws.message.model.UiwsMessageRcv;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* 쪽지(모듈 03) 서비스 원본 com.urp.uiws.message.service.MessageService MyBatis 변환 이식.
* 로직(전송/답장, 보낸함, 개봉현황, 받은함, 개봉처리, 다중삭제, 미열람 배지) 동등하게 보존한다.
* 기본 조회기간 = 최근 1주일.
*/
@Service
@RequiredArgsConstructor
public class MessageService {
private static final Set<String> VALID_RCV_TYPES = Set.of("RECV", "REF");
private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
private final MessageMapper mapper;
// ------------------------------------------------------------------ 전송/답장
@Transactional
public MessageSendResponse send(MessageSendDto dto) {
String actor = UiwsCurrentUser.id();
LocalDateTime now = LocalDateTime.now();
if (dto.replyToId() != null && !mapper.existsMessageById(dto.replyToId())) {
throw new UiwsApiException(UiwsErrorCode.MESSAGE_NOT_FOUND, "답장 원본 쪽지를 찾을 수 없습니다.");
}
UiwsMessage m = new UiwsMessage();
m.setSenderId(actor);
m.setTitle(dto.title());
m.setContent(dto.content());
m.setRefWorklogId(dto.refWorklogId());
m.setReplyToId(dto.replyToId());
m.setSentAt(now);
m.setSenderDelYn("N");
m.setCreatedBy(actor);
m.setCreatedAt(now);
mapper.insertMessage(m); // useGeneratedKeys m.messageId
// 수신자: 동일 수신자 중복 제거(UNIQUE(message_id,receiver_id) 보호). 등장 rcvType 채택.
Set<String> seen = new LinkedHashSet<>();
for (MessageReceiverDto r : dto.receivers()) {
String receiverId = (r.receiverId() == null) ? null : r.receiverId().trim();
String rcvType = (r.rcvType() == null) ? null : r.rcvType().trim().toUpperCase();
if (receiverId == null || receiverId.isBlank()) {
throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "수신자 ID가 비어 있습니다.");
}
if (!VALID_RCV_TYPES.contains(rcvType)) {
throw new UiwsApiException(UiwsErrorCode.MESSAGE_RCV_TYPE_INVALID);
}
if (!seen.add(receiverId)) {
continue;
}
UiwsMessageRcv rcv = new UiwsMessageRcv();
rcv.setMessageId(m.getMessageId());
rcv.setReceiverId(receiverId);
rcv.setRcvType(rcvType);
rcv.setReadYn("N");
rcv.setReceiverDelYn("N");
rcv.setCreatedBy(actor);
rcv.setCreatedAt(now);
mapper.insertRcv(rcv);
}
return new MessageSendResponse(m.getMessageId());
}
// ------------------------------------------------------------------ 보낸쪽지함
@Transactional(readOnly = true)
public SentPage listSent(LocalDate fromDate, LocalDate toDate, String titleKeyword, int page, int size) {
LocalDateTime[] range = range(fromDate, toDate);
String me = UiwsCurrentUser.id();
String kw = blankToNull(titleKeyword);
long total = mapper.countSent(me, range[0], range[1], kw);
List<UiwsMessage> rows = mapper.searchSent(me, range[0], range[1], kw, size, page * size);
List<Long> ids = rows.stream().map(UiwsMessage::getMessageId).toList();
List<UiwsMessageRcv> rcvs = ids.isEmpty() ? List.of() : mapper.findRcvByMessageIds(ids);
Map<Long, List<UiwsMessageRcv>> byMsg = rcvs.stream().collect(Collectors.groupingBy(UiwsMessageRcv::getMessageId));
Map<String, String> names = userNames(rcvs.stream().map(UiwsMessageRcv::getReceiverId).toList());
List<SentMessageDto> content = rows.stream().map(m -> {
List<UiwsMessageRcv> list = byMsg.getOrDefault(m.getMessageId(), List.of());
long openCount = list.stream().filter(r -> "Y".equals(r.getReadYn())).count();
return new SentMessageDto(m.getMessageId(), m.getTitle(),
receiverSummary(list, names), openCount, list.size(), fmt(m.getSentAt()));
}).toList();
return new SentPage(content, total, page, size, totalPages(total, size));
}
// ------------------------------------------------------------------ 보낸쪽지 상세
@Transactional(readOnly = true)
public SentMessageDetailDto sentDetail(Long messageId) {
String me = UiwsCurrentUser.id();
UiwsMessage m = mapper.findMessageById(messageId);
if (m == null) {
throw new UiwsApiException(UiwsErrorCode.MESSAGE_NOT_FOUND);
}
if (!me.equals(m.getSenderId())) {
throw new UiwsApiException(UiwsErrorCode.FORBIDDEN, "본인이 보낸 쪽지만 조회할 수 있습니다.");
}
List<UiwsMessageRcv> list = mapper.findRcvByMessageId(messageId);
Map<String, String> names = userNames(list.stream().map(UiwsMessageRcv::getReceiverId).toList());
List<ReceiverStatusDto> receivers = list.stream()
.map(r -> new ReceiverStatusDto(
names.getOrDefault(r.getReceiverId(), r.getReceiverId()),
r.getRcvType(), r.getReadYn(), fmt(r.getReadAt())))
.toList();
long total = receivers.size();
long open = list.stream().filter(r -> "Y".equals(r.getReadYn())).count();
return new SentMessageDetailDto(m.getMessageId(), m.getTitle(), m.getContent(), m.getRefWorklogId(),
fmt(m.getSentAt()), receivers, total, open, total - open);
}
// ------------------------------------------------------------------ 보낸쪽지 다중삭제
@Transactional
public void deleteSent(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
return;
}
String me = UiwsCurrentUser.id();
mapper.softDeleteSent(me, ids, me);
}
// ------------------------------------------------------------------ 미열람 (배지)
@Transactional(readOnly = true)
public long unreadCount() {
return mapper.countUnread(UiwsCurrentUser.id());
}
// ------------------------------------------------------------------ 받은쪽지함
@Transactional(readOnly = true)
public ReceivedPage listReceived(LocalDate fromDate, LocalDate toDate, String titleKeyword, int page, int size) {
LocalDateTime[] range = range(fromDate, toDate);
String me = UiwsCurrentUser.id();
String kw = blankToNull(titleKeyword);
long total = mapper.countReceived(me, range[0], range[1], kw);
List<Map<String, Object>> rows = mapper.searchReceived(me, range[0], range[1], kw, size, page * size);
Map<String, String> names = userNames(rows.stream().map(r -> str(r.get("senderId"))).toList());
List<ReceivedMessageDto> content = rows.stream()
.map(r -> new ReceivedMessageDto(
toLongObj(r.get("messageId")),
str(r.get("title")),
names.getOrDefault(str(r.get("senderId")), str(r.get("senderId"))),
fmt(toDt(r.get("sentAt"))),
str(r.get("readYn"))))
.toList();
return new ReceivedPage(content, total, page, size, totalPages(total, size));
}
// ------------------------------------------------------------------ 받은쪽지 상세(개봉처리)
@Transactional
public ReceivedMessageDetailDto receivedDetail(Long messageId) {
String me = UiwsCurrentUser.id();
UiwsMessageRcv rcv = mapper.findRcvByMessageAndReceiver(messageId, me);
if (rcv == null || "Y".equals(rcv.getReceiverDelYn())) {
throw new UiwsApiException(UiwsErrorCode.MESSAGE_NOT_FOUND, "받은 쪽지를 찾을 수 없습니다.");
}
UiwsMessage m = mapper.findMessageById(messageId);
if (m == null) {
throw new UiwsApiException(UiwsErrorCode.MESSAGE_NOT_FOUND);
}
LocalDateTime readAt = rcv.getReadAt();
if (!"Y".equals(rcv.getReadYn())) {
readAt = LocalDateTime.now();
mapper.markRead(rcv.getRcvId(), me, readAt);
}
String senderNm = userNames(List.of(m.getSenderId())).getOrDefault(m.getSenderId(), m.getSenderId());
return new ReceivedMessageDetailDto(m.getMessageId(), m.getTitle(), m.getContent(),
senderNm, fmt(m.getSentAt()), fmt(readAt), m.getRefWorklogId());
}
// ------------------------------------------------------------------ 받은쪽지 다중삭제
@Transactional
public void deleteReceived(List<Long> ids) {
if (ids == null || ids.isEmpty()) {
return;
}
String me = UiwsCurrentUser.id();
mapper.softDeleteReceived(me, ids, me);
}
// ================================================================== helpers
private LocalDateTime[] range(LocalDate fromDate, LocalDate toDate) {
LocalDate to = (toDate != null) ? toDate : LocalDate.now();
LocalDate from = (fromDate != null) ? fromDate : to.minusWeeks(1);
return new LocalDateTime[]{from.atStartOfDay(), to.atTime(LocalTime.MAX)};
}
private String receiverSummary(List<UiwsMessageRcv> list, Map<String, String> names) {
if (list.isEmpty()) {
return "";
}
String first = names.getOrDefault(list.get(0).getReceiverId(), list.get(0).getReceiverId());
return list.size() == 1 ? first : first + "" + (list.size() - 1) + "";
}
private Map<String, String> userNames(List<String> userIds) {
List<String> ids = userIds.stream().filter(s -> s != null && !s.isBlank()).distinct().toList();
if (ids.isEmpty()) {
return Map.of();
}
return mapper.findUserNames(ids).stream()
.collect(Collectors.toMap(r -> r.get("username"), r -> r.get("fullName"), (a, b) -> a));
}
private static int totalPages(long total, int size) {
return size <= 0 ? 0 : (int) ((total + size - 1) / size);
}
private static String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s;
}
private static String str(Object o) {
return o == null ? "" : o.toString();
}
private static Long toLongObj(Object o) {
if (o == null) {
return null;
}
return (o instanceof Number n) ? n.longValue() : Long.parseLong(o.toString());
}
private static LocalDateTime toDt(Object o) {
if (o == null) {
return null;
}
if (o instanceof LocalDateTime dt) {
return dt;
}
if (o instanceof java.sql.Timestamp ts) {
return ts.toLocalDateTime();
}
return null;
}
private static String fmt(LocalDateTime dt) {
return dt != null ? dt.format(DT) : null;
}
// 페이지 응답(ERP ApiResponse 안에 그대로 직렬화 content/totalElements/page/size/totalPages 보존)
public record SentPage(List<SentMessageDto> content, long totalElements, int page, int size, int totalPages) {
}
public record ReceivedPage(List<ReceivedMessageDto> content, long totalElements, int page, int size, int totalPages) {
}
}

View File

@ -1,68 +0,0 @@
package com.zioinfo.esn.uiws.schedule.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.uiws.schedule.dto.ScheduleDtos.AttachmentDto;
import com.zioinfo.esn.uiws.schedule.service.AttachmentService;
import lombok.RequiredArgsConstructor;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
/**
* UIWS 이식 첨부파일(모듈 02, 폴리모픽 SCHEDULE/DIARY). 인증 필수(/api/attachments).
*/
@RestController
@RequestMapping("/api/attachments")
@RequiredArgsConstructor
public class AttachmentController {
private final AttachmentService attachmentService;
@PostMapping
public ApiResponse<AttachmentDto> upload(
@RequestParam("refType") String refType,
@RequestParam("refId") Long refId,
@RequestParam("file") MultipartFile file) {
return ApiResponse.ok(attachmentService.upload(refType, refId, file));
}
@GetMapping("/{id}/download")
public ResponseEntity<Resource> download(@PathVariable("id") Long id) {
AttachmentService.DownloadFile f = attachmentService.download(id);
Resource resource = new FileSystemResource(f.path());
String contentType;
try {
contentType = Files.probeContentType(f.path());
} catch (IOException e) {
contentType = null;
}
ContentDisposition cd = ContentDisposition.attachment()
.filename(f.fileNm(), StandardCharsets.UTF_8)
.build();
HttpHeaders headers = new HttpHeaders();
headers.setContentDisposition(cd);
long len = f.path().toFile().length();
if (len > 0) {
headers.setContentLength(len);
}
return ResponseEntity.ok()
.headers(headers)
.contentType(contentType != null ? MediaType.parseMediaType(contentType) : MediaType.APPLICATION_OCTET_STREAM)
.body(resource);
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable("id") Long id) {
attachmentService.delete(id);
return ApiResponse.ok(null);
}
}

View File

@ -1,52 +0,0 @@
package com.zioinfo.esn.uiws.schedule.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.uiws.schedule.dto.ScheduleDtos.*;
import com.zioinfo.esn.uiws.schedule.service.DiaryService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
/**
* UIWS 이식 일지(모듈 02). 인증 필수(/api/diaries).
*/
@RestController
@RequestMapping("/api/diaries")
@RequiredArgsConstructor
public class DiaryController {
private final DiaryService diaryService;
@GetMapping
public ApiResponse<DiaryPage> list(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ApiResponse.ok(diaryService.list(fromDate, toDate, page, size));
}
@PostMapping
public ApiResponse<DiaryDetailDto> create(@Valid @RequestBody DiarySaveDto dto) {
return ApiResponse.ok(diaryService.create(dto));
}
@GetMapping("/{id}")
public ApiResponse<DiaryDetailDto> detail(@PathVariable("id") Long id) {
return ApiResponse.ok(diaryService.detail(id));
}
@PutMapping("/{id}")
public ApiResponse<DiaryDetailDto> update(@PathVariable("id") Long id, @Valid @RequestBody DiarySaveDto dto) {
return ApiResponse.ok(diaryService.update(id, dto));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable("id") Long id) {
diaryService.delete(id);
return ApiResponse.ok(null);
}
}

View File

@ -1,68 +0,0 @@
package com.zioinfo.esn.uiws.schedule.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.uiws.schedule.dto.ScheduleDtos.*;
import com.zioinfo.esn.uiws.schedule.service.ScheduleService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.List;
/**
* UIWS 이식 일정(모듈 02). 인증 필수(/api/schedules). 원본 엔드포인트 보존.
*/
@RestController
@RequestMapping("/api/schedules")
@RequiredArgsConstructor
public class ScheduleController {
private final ScheduleService scheduleService;
@GetMapping
public ApiResponse<List<ScheduleDto>> calendar(
@RequestParam(defaultValue = "PERSONAL") String type,
@RequestParam(defaultValue = "month") String view,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate baseDate,
@RequestParam(required = false) String deptId) {
return ApiResponse.ok(scheduleService.calendar(type, view, baseDate, deptId));
}
@GetMapping("/all")
public ApiResponse<SchedulePage> all(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate,
@RequestParam(required = false) String type,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ApiResponse.ok(scheduleService.all(fromDate, toDate, type, page, size));
}
@GetMapping("/search")
public ApiResponse<List<ScheduleDto>> search(@RequestParam(required = false) String keyword) {
return ApiResponse.ok(scheduleService.search(keyword));
}
@PostMapping
public ApiResponse<ScheduleDto> create(@Valid @RequestBody ScheduleSaveDto dto) {
return ApiResponse.ok(scheduleService.create(dto));
}
@GetMapping("/{id}")
public ApiResponse<ScheduleDetailDto> detail(@PathVariable("id") Long id) {
return ApiResponse.ok(scheduleService.detail(id));
}
@PutMapping("/{id}")
public ApiResponse<ScheduleDetailDto> update(@PathVariable("id") Long id, @Valid @RequestBody ScheduleSaveDto dto) {
return ApiResponse.ok(scheduleService.update(id, dto));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable("id") Long id) {
scheduleService.delete(id);
return ApiResponse.ok(null);
}
}

View File

@ -1,109 +0,0 @@
package com.zioinfo.esn.uiws.schedule.dto;
import com.zioinfo.esn.uiws.schedule.model.UiwsAttach;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import java.util.List;
/**
* 일정·일지·첨부(모듈 02) DTO. 원본 com.urp.uiws.schedule.dto.* 이식(shape 동일).
*/
public final class ScheduleDtos {
private ScheduleDtos() {
}
public record ScheduleDto(
Long scheduleId,
String scheType,
String title,
String scheGubunCd,
String importanceCd,
String startDt,
String endDt,
String ownerId,
String deptId
) {
}
public record ScheduleDetailDto(
Long scheduleId,
String scheType,
String title,
String scheGubunCd,
String importanceCd,
String startDt,
String endDt,
String ownerId,
String deptId,
String content,
String chargerId,
String chargerNm,
List<AttachmentDto> attachments
) {
}
public record ScheduleSaveDto(
@NotBlank @Pattern(regexp = "PERSONAL|DEPT") String scheType,
@NotBlank String title,
String scheGubunCd,
String importanceCd,
@NotNull String startDt,
@NotNull String endDt,
String content,
String deptId,
String chargerId,
List<Long> attachmentIds
) {
}
public record DiaryDto(
Long diaryId,
String title,
String writerNm,
String diaryDate,
Long scheduleId
) {
}
public record DiaryDetailDto(
Long diaryId,
String title,
String writerNm,
String diaryDate,
Long scheduleId,
String content,
List<AttachmentDto> attachments
) {
}
public record DiarySaveDto(
@NotBlank String title,
String content,
Long scheduleId,
String diaryDate,
List<Long> attachmentIds
) {
}
public record AttachmentDto(
Long attachId,
String refType,
Long refId,
String fileNm,
Long fileSize
) {
public static AttachmentDto from(UiwsAttach a) {
return new AttachmentDto(a.getAttachId(), a.getRefType(), a.getRefId(), a.getFileNm(), a.getFileSize());
}
}
/** 페이지 래퍼(content/totalElements/page/size/totalPages). */
public record DiaryPage(List<DiaryDto> content, long totalElements, int page, int size, int totalPages) {
}
public record SchedulePage(List<ScheduleDto> content, long totalElements, int page, int size, int totalPages) {
}
}

View File

@ -1,93 +0,0 @@
package com.zioinfo.esn.uiws.schedule.mapper;
import com.zioinfo.esn.uiws.schedule.model.UiwsAttach;
import com.zioinfo.esn.uiws.schedule.model.UiwsDiary;
import com.zioinfo.esn.uiws.schedule.model.UiwsSchedule;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 일정·일지·첨부 MyBatis 매퍼. 원본 JPA ScheduleRepository/DiaryRepository/AttachRepository 변환 이식.
* 데이터 가시범위: scopeAll(true=ADMIN 전체) OR owner IN (ownerIds). ownerIds 항상 본인 포함( IN 회피).
*/
@Mapper
public interface ScheduleMapper {
// schedule
int insertSchedule(UiwsSchedule s);
int updateSchedule(UiwsSchedule s);
UiwsSchedule findScheduleById(@Param("scheduleId") Long scheduleId);
boolean existsScheduleById(@Param("scheduleId") Long scheduleId);
int deleteScheduleById(@Param("scheduleId") Long scheduleId);
/** 달력: 기간 겹침([from,to) 배타 상한) + type + 개인소유/부서필터. */
List<UiwsSchedule> findInRange(@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
@Param("scheType") String scheType,
@Param("ownerId") String ownerId,
@Param("deptId") String deptId);
List<UiwsSchedule> searchAll(@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
@Param("scheType") String scheType,
@Param("scopeAll") boolean scopeAll,
@Param("ownerIds") List<String> ownerIds,
@Param("limit") int limit,
@Param("offset") int offset);
long countAll(@Param("from") LocalDateTime from,
@Param("to") LocalDateTime to,
@Param("scheType") String scheType,
@Param("scopeAll") boolean scopeAll,
@Param("ownerIds") List<String> ownerIds);
List<UiwsSchedule> searchPopup(@Param("keyword") String keyword,
@Param("scopeAll") boolean scopeAll,
@Param("ownerIds") List<String> ownerIds);
// diary
int insertDiary(UiwsDiary d);
int updateDiary(UiwsDiary d);
UiwsDiary findDiaryById(@Param("diaryId") Long diaryId);
boolean existsDiaryById(@Param("diaryId") Long diaryId);
int deleteDiaryById(@Param("diaryId") Long diaryId);
List<UiwsDiary> searchDiary(@Param("from") LocalDate from,
@Param("to") LocalDate to,
@Param("limit") int limit,
@Param("offset") int offset);
long countDiary(@Param("from") LocalDate from, @Param("to") LocalDate to);
// attach
int insertAttach(UiwsAttach a);
UiwsAttach findAttachById(@Param("attachId") Long attachId);
int updateAttachRef(@Param("attachId") Long attachId,
@Param("refType") String refType,
@Param("refId") Long refId,
@Param("actor") String actor);
List<UiwsAttach> findAttachByRef(@Param("refType") String refType, @Param("refId") Long refId);
int deleteAttachById(@Param("attachId") Long attachId);
int deleteAttachByRef(@Param("refType") String refType, @Param("refId") Long refId);
// 사용자명 라벨(코어 user 재사용)
List<Map<String, String>> findUserNames(@Param("ids") List<String> ids);
}

View File

@ -1,19 +0,0 @@
package com.zioinfo.esn.uiws.schedule.model;
import lombok.Data;
import java.time.LocalDateTime;
/** 첨부파일 폴리모픽 (tb_uiws_attach, ref_type=SCHEDULE|DIARY). 원본 com.urp.uiws.domain.Attach 이식. */
@Data
public class UiwsAttach {
private Long attachId;
private String refType;
private Long refId;
private String fileNm;
private String filePath;
private Long fileSize;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}

View File

@ -1,20 +0,0 @@
package com.zioinfo.esn.uiws.schedule.model;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/** 일지 (tb_uiws_diary). 원본 com.urp.uiws.domain.Diary 이식. */
@Data
public class UiwsDiary {
private Long diaryId;
private String title;
private String content;
private Long scheduleId;
private String writerId;
private LocalDate diaryDate;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}

View File

@ -1,24 +0,0 @@
package com.zioinfo.esn.uiws.schedule.model;
import lombok.Data;
import java.time.LocalDateTime;
/** 일정 (tb_uiws_schedule). 원본 com.urp.uiws.domain.Schedule 이식. */
@Data
public class UiwsSchedule {
private Long scheduleId;
private String scheType; // PERSONAL | DEPT
private String title;
private String scheGubunCd;
private String importanceCd;
private LocalDateTime startDt;
private LocalDateTime endDt;
private String content;
private String ownerId;
private String deptId;
private String chargerId;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}

View File

@ -1,121 +0,0 @@
package com.zioinfo.esn.uiws.schedule.service;
import com.zioinfo.esn.uiws.common.UiwsApiException;
import com.zioinfo.esn.uiws.common.UiwsCurrentUser;
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
import com.zioinfo.esn.uiws.schedule.dto.ScheduleDtos.AttachmentDto;
import com.zioinfo.esn.uiws.schedule.mapper.ScheduleMapper;
import com.zioinfo.esn.uiws.schedule.model.UiwsAttach;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.multipart.MultipartFile;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
/**
* 첨부파일(폴리모픽 SCHEDULE/DIARY) 업로드·삭제·연결. 원본 com.urp.uiws.schedule.service.AttachmentService 이식.
*/
@Service
@RequiredArgsConstructor
public class AttachmentService {
public static final String REF_SCHEDULE = "SCHEDULE";
public static final String REF_DIARY = "DIARY";
private static final Set<String> VALID_REF_TYPES = Set.of(REF_SCHEDULE, REF_DIARY);
private final ScheduleMapper mapper;
private final FileStorageService fileStorageService;
@Transactional
public AttachmentDto upload(String refType, Long refId, MultipartFile file) {
String type = normalizeRefType(refType);
if (refId == null) {
throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "refId 는 필수입니다.");
}
FileStorageService.StoredFile stored = fileStorageService.store(file);
String actor = UiwsCurrentUser.id();
UiwsAttach a = new UiwsAttach();
a.setRefType(type);
a.setRefId(refId);
a.setFileNm(stored.originalName());
a.setFilePath(stored.relativePath());
a.setFileSize(stored.size());
a.setCreatedBy(actor);
a.setCreatedAt(LocalDateTime.now());
mapper.insertAttach(a);
return AttachmentDto.from(a);
}
public record DownloadFile(String fileNm, Path path) {
}
@Transactional(readOnly = true)
public DownloadFile download(Long attachId) {
UiwsAttach a = mapper.findAttachById(attachId);
if (a == null) {
throw new UiwsApiException(UiwsErrorCode.ATTACH_NOT_FOUND);
}
Path path = fileStorageService.resolve(a.getFilePath());
if (!Files.exists(path)) {
throw new UiwsApiException(UiwsErrorCode.ATTACH_NOT_FOUND, "파일이 존재하지 않습니다.");
}
return new DownloadFile(a.getFileNm(), path);
}
@Transactional
public void delete(Long attachId) {
UiwsAttach a = mapper.findAttachById(attachId);
if (a == null) {
throw new UiwsApiException(UiwsErrorCode.ATTACH_NOT_FOUND);
}
fileStorageService.delete(a.getFilePath());
mapper.deleteAttachById(attachId);
}
/** 일정/일지 저장 시 attachmentIds 의 첨부들을 해당 대상으로 귀속(확정). */
@Transactional
public void link(String refType, Long refId, List<Long> attachmentIds) {
String type = normalizeRefType(refType);
if (attachmentIds == null || attachmentIds.isEmpty()) {
return;
}
String actor = UiwsCurrentUser.id();
for (Long id : attachmentIds) {
if (id == null) {
continue;
}
mapper.updateAttachRef(id, type, refId, actor);
}
}
@Transactional(readOnly = true)
public List<AttachmentDto> list(String refType, Long refId) {
return mapper.findAttachByRef(normalizeRefType(refType), refId)
.stream().map(AttachmentDto::from).toList();
}
/** 대상 삭제 시 귀속 첨부 일괄 제거(물리파일 포함). */
@Transactional
public void deleteByRef(String refType, Long refId) {
String type = normalizeRefType(refType);
List<UiwsAttach> rows = mapper.findAttachByRef(type, refId);
for (UiwsAttach a : rows) {
fileStorageService.delete(a.getFilePath());
}
mapper.deleteAttachByRef(type, refId);
}
private String normalizeRefType(String refType) {
String type = refType == null ? "" : refType.trim().toUpperCase();
if (!VALID_REF_TYPES.contains(type)) {
throw new UiwsApiException(UiwsErrorCode.ATTACH_REF_TYPE_INVALID);
}
return type;
}
}

View File

@ -1,133 +0,0 @@
package com.zioinfo.esn.uiws.schedule.service;
import com.zioinfo.esn.uiws.common.UiwsApiException;
import com.zioinfo.esn.uiws.common.UiwsCurrentUser;
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
import com.zioinfo.esn.uiws.schedule.dto.ScheduleDtos.*;
import com.zioinfo.esn.uiws.schedule.mapper.ScheduleMapper;
import com.zioinfo.esn.uiws.schedule.model.UiwsDiary;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 일지(모듈 02) 서비스 원본 com.urp.uiws.schedule.service.DiaryService MyBatis 변환 이식.
* 목록(페이징)·CRUD·첨부 연계.
*/
@Service
@RequiredArgsConstructor
public class DiaryService {
private final ScheduleMapper mapper;
private final AttachmentService attachmentService;
@Transactional(readOnly = true)
public DiaryPage list(LocalDate fromDate, LocalDate toDate, int page, int size) {
LocalDate to = (toDate != null) ? toDate : LocalDate.now();
LocalDate from = (fromDate != null) ? fromDate : to.minusMonths(1);
long total = mapper.countDiary(from, to);
List<UiwsDiary> rows = mapper.searchDiary(from, to, size, page * size);
Map<String, String> names = userNames(rows.stream().map(UiwsDiary::getWriterId).toList());
List<DiaryDto> content = rows.stream()
.map(d -> new DiaryDto(d.getDiaryId(), d.getTitle(),
names.getOrDefault(d.getWriterId(), d.getWriterId()),
d.getDiaryDate() != null ? d.getDiaryDate().toString() : null,
d.getScheduleId()))
.toList();
return new DiaryPage(content, total, page, size, totalPages(total, size));
}
@Transactional
public DiaryDetailDto create(DiarySaveDto dto) {
String actor = UiwsCurrentUser.id();
UiwsDiary d = new UiwsDiary();
applySave(d, dto);
d.setWriterId(actor);
d.setCreatedBy(actor);
d.setCreatedAt(LocalDateTime.now());
mapper.insertDiary(d);
attachmentService.link(AttachmentService.REF_DIARY, d.getDiaryId(), dto.attachmentIds());
return toDetail(d);
}
@Transactional(readOnly = true)
public DiaryDetailDto detail(Long id) {
return toDetail(find(id));
}
@Transactional
public DiaryDetailDto update(Long id, DiarySaveDto dto) {
String actor = UiwsCurrentUser.id();
UiwsDiary d = find(id);
applySave(d, dto);
d.setUpdatedBy(actor);
d.setUpdatedAt(LocalDateTime.now());
mapper.updateDiary(d);
attachmentService.link(AttachmentService.REF_DIARY, d.getDiaryId(), dto.attachmentIds());
return toDetail(d);
}
@Transactional
public void delete(Long id) {
if (!mapper.existsDiaryById(id)) {
throw new UiwsApiException(UiwsErrorCode.DIARY_NOT_FOUND);
}
attachmentService.deleteByRef(AttachmentService.REF_DIARY, id);
mapper.deleteDiaryById(id);
}
// ================================================================== helpers
private void applySave(UiwsDiary d, DiarySaveDto dto) {
d.setTitle(dto.title());
d.setContent(dto.content());
d.setScheduleId(dto.scheduleId());
d.setDiaryDate(parseDateNullable(dto.diaryDate()));
}
private DiaryDetailDto toDetail(UiwsDiary d) {
String writerNm = userNames(List.of(d.getWriterId())).getOrDefault(d.getWriterId(), d.getWriterId());
List<AttachmentDto> attachments = attachmentService.list(AttachmentService.REF_DIARY, d.getDiaryId());
return new DiaryDetailDto(d.getDiaryId(), d.getTitle(), writerNm,
d.getDiaryDate() != null ? d.getDiaryDate().toString() : null,
d.getScheduleId(), d.getContent(), attachments);
}
private UiwsDiary find(Long id) {
UiwsDiary d = mapper.findDiaryById(id);
if (d == null) {
throw new UiwsApiException(UiwsErrorCode.DIARY_NOT_FOUND);
}
return d;
}
private Map<String, String> userNames(List<String> ids) {
List<String> clean = ids.stream().filter(s -> s != null && !s.isBlank()).distinct().toList();
if (clean.isEmpty()) {
return Map.of();
}
return mapper.findUserNames(clean).stream()
.collect(Collectors.toMap(r -> r.get("username"), r -> r.get("fullName"), (a, b) -> a));
}
private static int totalPages(long total, int size) {
return size <= 0 ? 0 : (int) ((total + size - 1) / size);
}
private static LocalDate parseDateNullable(String s) {
if (s == null || s.isBlank()) {
return null;
}
try {
return LocalDate.parse(s);
} catch (Exception e) {
throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "날짜 형식이 올바르지 않습니다(yyyy-MM-dd).");
}
}
}

View File

@ -1,93 +0,0 @@
package com.zioinfo.esn.uiws.schedule.service;
import com.zioinfo.esn.uiws.common.UiwsApiException;
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
import com.zioinfo.esn.uiws.config.UiwsProperties;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
/**
* 첨부파일 로컬 스토리지(UIWS 이식). 경로순회 방지(파일명 정규화 + UUID 저장명), 일자별 디렉터리 분리.
* 저장 결과로 상대경로(file_path) 반환한다.
*/
@Service
@RequiredArgsConstructor
public class FileStorageService {
private static final DateTimeFormatter DAY = DateTimeFormatter.ofPattern("yyyy/MM/dd");
private final UiwsProperties properties;
public record StoredFile(String originalName, String relativePath, long size) {
}
public StoredFile store(MultipartFile file) {
if (file == null || file.isEmpty()) {
throw new UiwsApiException(UiwsErrorCode.FILE_EMPTY);
}
String original = StringUtils.cleanPath(
file.getOriginalFilename() != null ? file.getOriginalFilename() : "file");
original = original.replace("\\", "_").replace("/", "_");
if (original.contains("..")) {
original = original.replace("..", "_");
}
String ext = "";
int dot = original.lastIndexOf('.');
if (dot >= 0) {
ext = original.substring(dot);
}
String subDir = LocalDate.now().format(DAY);
String storedName = UUID.randomUUID().toString().replace("-", "") + ext;
String relativePath = subDir + "/" + storedName;
try {
Path root = Paths.get(properties.getUpload().getUploadDir()).toAbsolutePath().normalize();
Path target = root.resolve(relativePath).normalize();
if (!target.startsWith(root)) {
throw new UiwsApiException(UiwsErrorCode.FILE_STORAGE_ERROR, "허용되지 않은 저장 경로입니다.");
}
Files.createDirectories(target.getParent());
file.transferTo(target.toFile());
} catch (IOException e) {
throw new UiwsApiException(UiwsErrorCode.FILE_STORAGE_ERROR);
}
return new StoredFile(original, relativePath, file.getSize());
}
public Path resolve(String relativePath) {
if (relativePath == null || relativePath.isBlank()) {
throw new UiwsApiException(UiwsErrorCode.ATTACH_NOT_FOUND);
}
Path root = Paths.get(properties.getUpload().getUploadDir()).toAbsolutePath().normalize();
Path target = root.resolve(relativePath).normalize();
if (!target.startsWith(root)) {
throw new UiwsApiException(UiwsErrorCode.FILE_STORAGE_ERROR, "허용되지 않은 경로입니다.");
}
return target;
}
public void delete(String relativePath) {
if (relativePath == null || relativePath.isBlank()) {
return;
}
try {
Path root = Paths.get(properties.getUpload().getUploadDir()).toAbsolutePath().normalize();
Path target = root.resolve(relativePath).normalize();
if (target.startsWith(root)) {
Files.deleteIfExists(target);
}
} catch (IOException ignore) {
// 물리파일 삭제 실패는 무시(메타 일관성 우선)
}
}
}

View File

@ -1,216 +0,0 @@
package com.zioinfo.esn.uiws.schedule.service;
import com.zioinfo.esn.uiws.common.UiwsApiException;
import com.zioinfo.esn.uiws.common.UiwsCurrentUser;
import com.zioinfo.esn.uiws.common.UiwsDataScope;
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
import com.zioinfo.esn.uiws.schedule.dto.ScheduleDtos.*;
import com.zioinfo.esn.uiws.schedule.mapper.ScheduleMapper;
import com.zioinfo.esn.uiws.schedule.model.UiwsSchedule;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.temporal.TemporalAdjusters;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 일정(모듈 02) 서비스 원본 com.urp.uiws.schedule.service.ScheduleService MyBatis 변환 이식.
* 달력(month/week/day)·전체목록·검색팝업·CRUD. 첨부 연계(attachmentIds).
*/
@Service
@RequiredArgsConstructor
public class ScheduleService {
private static final String TYPE_PERSONAL = "PERSONAL";
private static final String TYPE_DEPT = "DEPT";
private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
private final ScheduleMapper mapper;
private final AttachmentService attachmentService;
@Transactional(readOnly = true)
public List<ScheduleDto> calendar(String type, String view, LocalDate baseDate, String deptId) {
String t = normalizeType(type);
LocalDate base = (baseDate != null) ? baseDate : LocalDate.now();
LocalDate[] range = computeRange(view, base);
LocalDateTime from = range[0].atStartOfDay();
LocalDateTime to = range[1].atStartOfDay(); // 배타 상한
String ownerId = TYPE_PERSONAL.equals(t) ? UiwsCurrentUser.id() : null;
String deptFilter = TYPE_DEPT.equals(t) ? blankToNull(deptId) : null;
return mapper.findInRange(from, to, t, ownerId, deptFilter).stream().map(this::toDto).toList();
}
@Transactional(readOnly = true)
public SchedulePage all(LocalDate fromDate, LocalDate toDate, String type, int page, int size) {
LocalDate to = (toDate != null) ? toDate : LocalDate.now();
LocalDate from = (fromDate != null) ? fromDate : to.minusMonths(1);
String t = (type == null || type.isBlank()) ? null : normalizeType(type);
UiwsDataScope.Scope scope = UiwsDataScope.current();
LocalDateTime f = from.atStartOfDay();
LocalDateTime tt = to.plusDays(1).atStartOfDay();
long total = mapper.countAll(f, tt, t, scope.all(), scope.ownerIds());
List<ScheduleDto> content = mapper.searchAll(f, tt, t, scope.all(), scope.ownerIds(), size, page * size)
.stream().map(this::toDto).toList();
return new SchedulePage(content, total, page, size, totalPages(total, size));
}
@Transactional(readOnly = true)
public List<ScheduleDto> search(String keyword) {
UiwsDataScope.Scope scope = UiwsDataScope.current();
return mapper.searchPopup(blankToNull(keyword), scope.all(), scope.ownerIds())
.stream().map(this::toDto).toList();
}
@Transactional
public ScheduleDto create(ScheduleSaveDto dto) {
String actor = UiwsCurrentUser.id();
UiwsSchedule s = new UiwsSchedule();
applySave(s, dto);
s.setOwnerId(actor);
s.setCreatedBy(actor);
s.setCreatedAt(LocalDateTime.now());
mapper.insertSchedule(s);
attachmentService.link(AttachmentService.REF_SCHEDULE, s.getScheduleId(), dto.attachmentIds());
return toDto(s);
}
@Transactional(readOnly = true)
public ScheduleDetailDto detail(Long id) {
return toDetail(find(id));
}
@Transactional
public ScheduleDetailDto update(Long id, ScheduleSaveDto dto) {
String actor = UiwsCurrentUser.id();
UiwsSchedule s = find(id);
applySave(s, dto);
s.setUpdatedBy(actor);
s.setUpdatedAt(LocalDateTime.now());
mapper.updateSchedule(s);
attachmentService.link(AttachmentService.REF_SCHEDULE, s.getScheduleId(), dto.attachmentIds());
return toDetail(s);
}
@Transactional
public void delete(Long id) {
if (!mapper.existsScheduleById(id)) {
throw new UiwsApiException(UiwsErrorCode.SCHEDULE_NOT_FOUND);
}
attachmentService.deleteByRef(AttachmentService.REF_SCHEDULE, id);
mapper.deleteScheduleById(id);
}
// ================================================================== helpers
private void applySave(UiwsSchedule s, ScheduleSaveDto dto) {
LocalDateTime start = parseDateTime(dto.startDt());
LocalDateTime end = parseDateTime(dto.endDt());
if (end.isBefore(start)) {
throw new UiwsApiException(UiwsErrorCode.SCHEDULE_DT_INVALID, "종료 일시가 시작 일시보다 빠릅니다.");
}
s.setScheType(normalizeType(dto.scheType()));
s.setTitle(dto.title());
s.setScheGubunCd(blankToNull(dto.scheGubunCd()));
s.setImportanceCd(blankToNull(dto.importanceCd()));
s.setStartDt(start);
s.setEndDt(end);
s.setContent(dto.content());
s.setDeptId(blankToNull(dto.deptId()));
s.setChargerId(blankToNull(dto.chargerId()));
}
private ScheduleDto toDto(UiwsSchedule s) {
return new ScheduleDto(s.getScheduleId(), s.getScheType(), s.getTitle(),
s.getScheGubunCd(), s.getImportanceCd(), fmt(s.getStartDt()), fmt(s.getEndDt()),
s.getOwnerId(), s.getDeptId());
}
private ScheduleDetailDto toDetail(UiwsSchedule s) {
String chargerNm = null;
if (s.getChargerId() != null) {
chargerNm = userNames(List.of(s.getChargerId())).getOrDefault(s.getChargerId(), s.getChargerId());
}
List<AttachmentDto> attachments = attachmentService.list(AttachmentService.REF_SCHEDULE, s.getScheduleId());
return new ScheduleDetailDto(s.getScheduleId(), s.getScheType(), s.getTitle(),
s.getScheGubunCd(), s.getImportanceCd(), fmt(s.getStartDt()), fmt(s.getEndDt()),
s.getOwnerId(), s.getDeptId(), s.getContent(), s.getChargerId(), chargerNm, attachments);
}
private UiwsSchedule find(Long id) {
UiwsSchedule s = mapper.findScheduleById(id);
if (s == null) {
throw new UiwsApiException(UiwsErrorCode.SCHEDULE_NOT_FOUND);
}
return s;
}
private Map<String, String> userNames(List<String> ids) {
List<String> clean = ids.stream().filter(s -> s != null && !s.isBlank()).distinct().toList();
if (clean.isEmpty()) {
return Map.of();
}
return mapper.findUserNames(clean).stream()
.collect(Collectors.toMap(r -> r.get("username"), r -> r.get("fullName"), (a, b) -> a));
}
private LocalDate[] computeRange(String view, LocalDate base) {
String v = (view == null || view.isBlank()) ? "month" : view.trim().toLowerCase();
return switch (v) {
case "day" -> new LocalDate[]{base, base.plusDays(1)};
case "week" -> {
LocalDate start = base.with(TemporalAdjusters.previousOrSame(DayOfWeek.MONDAY));
yield new LocalDate[]{start, start.plusWeeks(1)};
}
case "month" -> {
LocalDate start = base.withDayOfMonth(1);
yield new LocalDate[]{start, start.plusMonths(1)};
}
default -> throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST,
"view 는 month/week/day 중 하나여야 합니다.");
};
}
private String normalizeType(String type) {
String t = (type == null) ? "" : type.trim().toUpperCase();
if (!TYPE_PERSONAL.equals(t) && !TYPE_DEPT.equals(t)) {
throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "type 은 PERSONAL/DEPT 중 하나여야 합니다.");
}
return t;
}
private static int totalPages(long total, int size) {
return size <= 0 ? 0 : (int) ((total + size - 1) / size);
}
private static String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s;
}
private static String fmt(LocalDateTime dt) {
return dt != null ? dt.format(DT) : null;
}
private static LocalDateTime parseDateTime(String s) {
if (s == null || s.isBlank()) {
throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "일시 값이 비어 있습니다.");
}
try {
if (s.length() <= 10) {
return LocalDate.parse(s).atStartOfDay();
}
return LocalDateTime.parse(s);
} catch (Exception e) {
throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST,
"일시 형식이 올바르지 않습니다(yyyy-MM-ddTHH:mm:ss).");
}
}
}

View File

@ -1,43 +0,0 @@
package com.zioinfo.esn.uiws.stats.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.uiws.stats.dto.StatsDtos.PivotResponse;
import com.zioinfo.esn.uiws.stats.service.StatsService;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
/**
* UIWS 이식 업무통계(모듈 04, 동적 컬럼 피벗). 인증 필수(/api/stats).
* prefix /api/stats ERP 기존 라우트와 미충돌(확인). 원본 엔드포인트 보존.
*/
@RestController
@RequestMapping("/api/stats")
@RequiredArgsConstructor
public class StatsController {
private final StatsService statsService;
@GetMapping("/personal-work")
public ApiResponse<PivotResponse> personalWork(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate,
@RequestParam(required = false) String deptId,
@RequestParam(required = false) String userId,
@RequestParam(required = false) Boolean showStatus,
@RequestParam(required = false) Boolean showType) {
return ApiResponse.ok(statsService.personalWork(fromDate, toDate, deptId, userId, showStatus, showType));
}
@GetMapping("/company-work")
public ApiResponse<PivotResponse> companyWork(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate,
@RequestParam(required = false) String companyId,
@RequestParam(required = false) String userId,
@RequestParam(required = false) Boolean showType) {
return ApiResponse.ok(statsService.companyWork(fromDate, toDate, companyId, userId, showType));
}
}

View File

@ -1,25 +0,0 @@
package com.zioinfo.esn.uiws.stats.dto;
import java.util.List;
import java.util.Map;
/**
* 근무현황 통계(모듈 04) 동적 컬럼 피벗 DTO. 원본 com.urp.uiws.stats.dto.* 이식.
*/
public final class StatsDtos {
private StatsDtos() {
}
/** 피벗 열 메타. 고정열 예 {key:"worker",label:"근무자"}; 동적열 예 {key:"company_본사",label:"본사"}. */
public record PivotColumn(String key, String label) {
}
/** 동적 컬럼 피벗 응답. { fixedColumns, dynamicColumns, rows } */
public record PivotResponse(
List<PivotColumn> fixedColumns,
List<PivotColumn> dynamicColumns,
List<Map<String, Object>> rows
) {
}
}

View File

@ -1,36 +0,0 @@
package com.zioinfo.esn.uiws.stats.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDate;
import java.util.List;
import java.util.Map;
/**
* 근무현황 피벗 집계 매퍼. 원본 StatsRepository(JdbcTemplate 네이티브) MyBatis 변환 이식.
*
* silo 정합(코어 TB_USER/TB_COMPANY/TB_CODE 미이식):
* - 근무자 라벨: esn_user (writer_id=username 라벨). 미존재 writer_id 노출(COALESCE).
* - 근무처(company) 라벨: company_id 직접 사용(거래처 테이블 미이식).
* - 근무상태/근무유형 라벨: 코드값 직접 사용(공통코드 미이식).
* 결과는 long-form(그룹키 + cnt). 서비스가 동적 컬럼으로 피벗한다.
*/
@Mapper
public interface StatsMapper {
/** 개인별: 행=근무자×근무상태×근무유형, 동적열 차원=근무처. 컬럼: worker, work_status, work_type, dyn, cnt. */
List<Map<String, Object>> personalWork(@Param("from") LocalDate from,
@Param("to") LocalDate to,
@Param("userId") String userId,
@Param("scopeAll") boolean scopeAll,
@Param("ownerIds") List<String> ownerIds);
/** 업체별: 행=근무처×근무유형, 동적열 차원=근무자. 컬럼: company, work_type, dyn, cnt. */
List<Map<String, Object>> companyWork(@Param("from") LocalDate from,
@Param("to") LocalDate to,
@Param("companyId") String companyId,
@Param("userId") String userId,
@Param("scopeAll") boolean scopeAll,
@Param("ownerIds") List<String> ownerIds);
}

View File

@ -1,165 +0,0 @@
package com.zioinfo.esn.uiws.stats.service;
import com.zioinfo.esn.uiws.common.UiwsDataScope;
import com.zioinfo.esn.uiws.stats.dto.StatsDtos.PivotColumn;
import com.zioinfo.esn.uiws.stats.dto.StatsDtos.PivotResponse;
import com.zioinfo.esn.uiws.stats.mapper.StatsMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 근무현황 통계(모듈 04) 동적 컬럼 피벗 서비스. 원본 com.urp.uiws.stats.service.StatsService 이식.
* long-form 집계를 wide-form 으로 피벗. showStatus/showType 토글. 기본 조회기간 = 최근 1주일.
* 데이터 스코프(ADMIN=전체/ =본인) UiwsDataScope silo 판정.
*/
@Service
@RequiredArgsConstructor
public class StatsService {
/** 행 식별 키 구분자(데이터에 등장하지 않는 제어문자). */
private static final char SEP = '';
private final StatsMapper mapper;
@Transactional(readOnly = true)
public PivotResponse personalWork(LocalDate fromDate, LocalDate toDate,
String deptId, String userId,
Boolean showStatus, Boolean showType) {
LocalDate[] range = range(fromDate, toDate);
UiwsDataScope.Scope scope = UiwsDataScope.current();
// deptId 코어 dept 미이식으로 silo 에서 무시(파라미터 호환만 유지).
List<Map<String, Object>> agg = mapper.personalWork(
range[0], range[1], emptyToNull(userId), scope.all(), scope.ownerIds());
boolean status = !Boolean.FALSE.equals(showStatus);
boolean type = !Boolean.FALSE.equals(showType);
List<PivotColumn> fixed = new ArrayList<>();
fixed.add(new PivotColumn("worker", "근무자"));
if (status) {
fixed.add(new PivotColumn("workStatus", "근무상태"));
}
if (type) {
fixed.add(new PivotColumn("workType", "근무유형"));
}
return pivot(agg, fixed, "company",
row -> rowKey(row, status, type),
row -> {
Map<String, Object> base = new LinkedHashMap<>();
base.put("worker", str(row.get("worker")));
if (status) {
base.put("workStatus", str(row.get("work_status")));
}
if (type) {
base.put("workType", str(row.get("work_type")));
}
return base;
});
}
@Transactional(readOnly = true)
public PivotResponse companyWork(LocalDate fromDate, LocalDate toDate,
String companyId, String userId,
Boolean showType) {
LocalDate[] range = range(fromDate, toDate);
UiwsDataScope.Scope scope = UiwsDataScope.current();
List<Map<String, Object>> agg = mapper.companyWork(
range[0], range[1], emptyToNull(companyId), emptyToNull(userId), scope.all(), scope.ownerIds());
boolean type = !Boolean.FALSE.equals(showType);
List<PivotColumn> fixed = new ArrayList<>();
fixed.add(new PivotColumn("company", "근무처"));
if (type) {
fixed.add(new PivotColumn("workType", "근무유형"));
}
return pivot(agg, fixed, "worker",
row -> str(row.get("company")) + SEP + (type ? str(row.get("work_type")) : ""),
row -> {
Map<String, Object> base = new LinkedHashMap<>();
base.put("company", str(row.get("company")));
if (type) {
base.put("workType", str(row.get("work_type")));
}
return base;
});
}
// ================================================================== 피벗 공통
private interface RowKeyFn {
String key(Map<String, Object> row);
}
private interface BaseFn {
Map<String, Object> base(Map<String, Object> row);
}
private PivotResponse pivot(List<Map<String, Object>> agg, List<PivotColumn> fixed,
String dynPrefix, RowKeyFn keyFn, BaseFn baseFn) {
Map<String, PivotColumn> dynCols = new LinkedHashMap<>();
Map<String, Map<String, Object>> rowMap = new LinkedHashMap<>();
for (Map<String, Object> r : agg) {
String dynLabel = str(r.get("dyn"));
String dynKey = dynPrefix + "_" + dynLabel;
dynCols.putIfAbsent(dynKey, new PivotColumn(dynKey, dynLabel));
String rk = keyFn.key(r);
Map<String, Object> row = rowMap.computeIfAbsent(rk, k -> baseFn.base(r));
long cnt = toLong(r.get("cnt"));
row.merge(dynKey, cnt, (a, b) -> toLong(a) + toLong(b));
}
List<PivotColumn> dynamic = new ArrayList<>(dynCols.values());
List<Map<String, Object>> rows = new ArrayList<>();
for (Map<String, Object> row : rowMap.values()) {
for (PivotColumn dc : dynamic) {
row.putIfAbsent(dc.key(), 0L);
}
rows.add(row);
}
return new PivotResponse(fixed, dynamic, rows);
}
private String rowKey(Map<String, Object> row, boolean status, boolean type) {
StringBuilder sb = new StringBuilder(str(row.get("worker")));
if (status) {
sb.append(SEP).append(str(row.get("work_status")));
}
if (type) {
sb.append(SEP).append(str(row.get("work_type")));
}
return sb.toString();
}
private LocalDate[] range(LocalDate fromDate, LocalDate toDate) {
LocalDate to = (toDate != null) ? toDate : LocalDate.now();
LocalDate from = (fromDate != null) ? fromDate : to.minusWeeks(1);
return new LocalDate[]{from, to};
}
private static String emptyToNull(String s) {
return (s == null || s.isBlank()) ? null : s;
}
private static String str(Object o) {
return o == null ? "" : o.toString();
}
private static long toLong(Object o) {
if (o == null) {
return 0L;
}
return (o instanceof Number n) ? n.longValue() : Long.parseLong(o.toString());
}
}

View File

@ -1,94 +0,0 @@
package com.zioinfo.esn.uiws.worklog.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.uiws.worklog.dto.WorklogDtos.*;
import com.zioinfo.esn.uiws.worklog.service.WorklogService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.List;
/**
* UIWS 이식 업무일지(모듈 06). 인증 필수(/api/worklogs).
* 원본 /pdf(JasperReports) 엔드포인트는 ERP 의존성 미보유로 제외(보고서는 후속 트랙 esn_port.md 참조).
*/
@RestController
@RequestMapping("/api/worklogs")
@RequiredArgsConstructor
public class WorklogController {
private final WorklogService worklogService;
@GetMapping
public ApiResponse<WorklogPage> list(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate,
@RequestParam(required = false) String writerId,
@RequestParam(required = false) String progressCd,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
return ApiResponse.ok(worklogService.list(fromDate, toDate, writerId, progressCd, page, size));
}
@GetMapping("/dashboard/progress")
public ApiResponse<List<ProgressSummaryDto>> progressSummary(
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate) {
return ApiResponse.ok(worklogService.progressSummary(fromDate, toDate));
}
@GetMapping("/calendar")
public ApiResponse<List<WorklogCalendarDto>> calendar(
@RequestParam(required = false) String yearMonth,
@RequestParam(required = false) String writerId) {
return ApiResponse.ok(worklogService.calendar(yearMonth, writerId));
}
@GetMapping("/search")
public ApiResponse<List<WorklogListDto>> search(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String writerId) {
return ApiResponse.ok(worklogService.search(keyword, writerId));
}
@PostMapping
public ApiResponse<WorklogDetailDto> create(@Valid @RequestBody WorklogSaveDto dto) {
return ApiResponse.ok(worklogService.create(dto));
}
@GetMapping("/{id}")
public ApiResponse<WorklogDetailDto> detail(@PathVariable("id") Long id) {
return ApiResponse.ok(worklogService.detail(id));
}
@PutMapping("/{id}")
public ApiResponse<WorklogDetailDto> update(@PathVariable("id") Long id, @Valid @RequestBody WorklogSaveDto dto) {
return ApiResponse.ok(worklogService.update(id, dto));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable("id") Long id) {
worklogService.delete(id);
return ApiResponse.ok(null);
}
@PostMapping("/{id}/comments")
public ApiResponse<WorklogCommentDto> addComment(@PathVariable("id") Long id,
@Valid @RequestBody CommentRequest req) {
return ApiResponse.ok(worklogService.addComment(id, req));
}
@PostMapping("/comments/{cmtId}/confirm")
public ApiResponse<Void> confirmComment(@PathVariable("cmtId") Long cmtId) {
worklogService.confirmComment(cmtId);
return ApiResponse.ok(null);
}
@GetMapping("/comments/unconfirmed")
public ApiResponse<List<UnconfirmedCommentDto>> unconfirmedComments() {
return ApiResponse.ok(worklogService.unconfirmedByMe());
}
}

View File

@ -1,118 +0,0 @@
package com.zioinfo.esn.uiws.worklog.dto;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.zioinfo.esn.uiws.worklog.model.UiwsWorklogDtl;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.List;
/**
* 업무일지(모듈 06) DTO. 원본 com.urp.uiws.worklog.dto.* 이식(shape 동일, _state 보존).
*/
public final class WorklogDtos {
private WorklogDtos() {
}
public record WorklogListDto(
Long worklogId,
String title,
String writerNm,
String workDate,
String progressCd,
long commentCount
) {
}
public record WorklogCalendarDto(
String workDate,
Long worklogId,
String workStatusNm,
boolean isHoliday,
String holidayNm
) {
}
public record WorklogDetailDto(
Long worklogId,
String title,
String writerId,
String writerNm,
String workDate,
String workStatusCd,
String progressCd,
String repeatYn,
String repeatStartDate,
String repeatEndDate,
List<WorklogDtlDto> details,
List<WorklogCommentDto> comments
) {
}
public record WorklogDtlDto(
Long dtlId,
Integer startHour,
Integer endHour,
String workTypeCd,
String companyId,
String workContent,
String issueContent,
Integer sortOrd,
String createdAt,
String updatedAt,
@JsonProperty("_state") String state
) {
public static WorklogDtlDto from(UiwsWorklogDtl d) {
return new WorklogDtlDto(
d.getDtlId(), d.getStartHour(), d.getEndHour(), d.getWorkTypeCd(), d.getCompanyId(),
d.getWorkContent(), d.getIssueContent(), d.getSortOrd(),
d.getCreatedAt() != null ? d.getCreatedAt().toString() : null,
d.getUpdatedAt() != null ? d.getUpdatedAt().toString() : null,
null);
}
}
public record WorklogSaveDto(
@NotBlank String title,
@NotBlank String writerId,
@NotNull String workDate,
@NotBlank String workStatusCd,
String progressCd,
String repeatYn,
String repeatStartDate,
String repeatEndDate,
@Valid List<WorklogDtlDto> details
) {
}
public record WorklogCommentDto(
Long cmtId,
String cmtContent,
String writerNm,
String createdAt,
String kakaoSentYn,
String confirmYn
) {
}
public record CommentRequest(@NotBlank String cmtContent) {
}
public record ProgressSummaryDto(String progressCd, String progressNm, long count) {
}
public record UnconfirmedCommentDto(
Long cmtId,
Long worklogId,
String worklogTitle,
String writerNm,
String cmtContent,
String createdAt
) {
}
public record WorklogPage(List<WorklogListDto> content, long totalElements, int page, int size, int totalPages) {
}
}

View File

@ -1,96 +0,0 @@
package com.zioinfo.esn.uiws.worklog.mapper;
import com.zioinfo.esn.uiws.worklog.model.UiwsWorklog;
import com.zioinfo.esn.uiws.worklog.model.UiwsWorklogCmt;
import com.zioinfo.esn.uiws.worklog.model.UiwsWorklogDtl;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
/**
* 업무일지 MyBatis 매퍼. 원본 JPA Worklog/WorklogDtl/WorklogCmt Repository 변환 이식.
* 진행상태 집계는 ported tb_uiws_worklog 기반(코어 TB_CODE 미이식 코드값 직접 집계).
*/
@Mapper
public interface WorklogMapper {
// worklog 헤더
int insertWorklog(UiwsWorklog w);
int updateWorklog(UiwsWorklog w);
UiwsWorklog findWorklogById(@Param("worklogId") Long worklogId);
boolean existsWorklogById(@Param("worklogId") Long worklogId);
int deleteWorklogById(@Param("worklogId") Long worklogId);
List<UiwsWorklog> searchList(@Param("from") LocalDate from,
@Param("to") LocalDate to,
@Param("writerId") String writerId,
@Param("progressCd") String progressCd,
@Param("scopeAll") boolean scopeAll,
@Param("ownerIds") List<String> ownerIds,
@Param("limit") int limit,
@Param("offset") int offset);
long countList(@Param("from") LocalDate from,
@Param("to") LocalDate to,
@Param("writerId") String writerId,
@Param("progressCd") String progressCd,
@Param("scopeAll") boolean scopeAll,
@Param("ownerIds") List<String> ownerIds);
List<UiwsWorklog> findByMonth(@Param("from") LocalDate from,
@Param("to") LocalDate to,
@Param("writerId") String writerId,
@Param("scopeAll") boolean scopeAll,
@Param("ownerIds") List<String> ownerIds);
List<UiwsWorklog> searchPopup(@Param("keyword") String keyword,
@Param("writerId") String writerId,
@Param("scopeAll") boolean scopeAll,
@Param("ownerIds") List<String> ownerIds);
/** 진행상태별 건수(progress_cd, cnt). */
List<Map<String, Object>> progressSummary(@Param("from") LocalDate from,
@Param("to") LocalDate to,
@Param("scopeAll") boolean scopeAll,
@Param("ownerIds") List<String> ownerIds);
// 상세(dtl)
int insertDtl(UiwsWorklogDtl d);
int updateDtl(UiwsWorklogDtl d);
int deleteDtlById(@Param("dtlId") Long dtlId);
List<UiwsWorklogDtl> findDtlByWorklogId(@Param("worklogId") Long worklogId);
// 댓글(cmt)
int insertCmt(UiwsWorklogCmt c);
UiwsWorklogCmt findCmtById(@Param("cmtId") Long cmtId);
int updateCmtKakaoSent(@Param("cmtId") Long cmtId);
int confirmCmt(@Param("cmtId") Long cmtId, @Param("actor") String actor);
List<UiwsWorklogCmt> findCmtByWorklogId(@Param("worklogId") Long worklogId);
List<Map<String, Object>> countCommentsByWorklogIds(@Param("ids") List<Long> ids);
List<UiwsWorklogCmt> findUnconfirmedByWriter(@Param("writerId") String writerId);
// 사용자명 라벨(코어 user 재사용)
List<Map<String, String>> findUserNames(@Param("ids") List<String> ids);
/** 댓글 알림 메일 발송 대상 이메일(username 기준). */
String findUserEmail(@Param("username") String username);
List<UiwsWorklog> findWorklogsByIds(@Param("ids") List<Long> ids);
}

View File

@ -1,23 +0,0 @@
package com.zioinfo.esn.uiws.worklog.model;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
/** 업무일지 헤더 (tb_uiws_worklog). 원본 com.urp.uiws.domain.Worklog 이식. */
@Data
public class UiwsWorklog {
private Long worklogId;
private String title;
private String writerId;
private LocalDate workDate;
private String workStatusCd;
private String progressCd; // ONGOING | DONE
private String repeatYn; // Y | N
private LocalDate repeatStartDate;
private LocalDate repeatEndDate;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}

View File

@ -1,19 +0,0 @@
package com.zioinfo.esn.uiws.worklog.model;
import lombok.Data;
import java.time.LocalDateTime;
/** 업무일지 댓글 (tb_uiws_worklog_cmt). 원본 com.urp.uiws.domain.WorklogCmt 이식. */
@Data
public class UiwsWorklogCmt {
private Long cmtId;
private Long worklogId;
private String cmtContent;
private String writerId;
private String kakaoSentYn;
private String confirmYn;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}

View File

@ -1,22 +0,0 @@
package com.zioinfo.esn.uiws.worklog.model;
import lombok.Data;
import java.time.LocalDateTime;
/** 업무일지 시간대별 상세 (tb_uiws_worklog_dtl). 원본 com.urp.uiws.domain.WorklogDtl 이식. */
@Data
public class UiwsWorklogDtl {
private Long dtlId;
private Long worklogId;
private Integer startHour;
private Integer endHour;
private String workTypeCd;
private String companyId;
private String workContent;
private String issueContent;
private Integer sortOrd;
private String createdBy;
private LocalDateTime createdAt;
private String updatedBy;
private LocalDateTime updatedAt;
}

View File

@ -1,31 +0,0 @@
package com.zioinfo.esn.uiws.worklog.service;
import com.zioinfo.esn.uiws.common.mail.MailSender;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 업무일지 댓글 알림 원본 NaverWorksNotifier(외부 메신저) 대신 MailSender 폴백으로 이식.
* 보안 불변규칙(외부 API 금지) 준수: 외부 메신저 호출 없이 메일/로그 채널로만 알림한다.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class WorklogNotifier {
private final MailSender mailSender;
/**
* 일지 작성자에게 댓글 알림. 발송되면 true(=KAKAO_SENT_YN 'Y' 선반영).
* email 미보유 로그만 남기고 false.
*/
public boolean notifyComment(String writerId, String email, String subject, String body) {
if (email == null || email.isBlank()) {
log.info("[UIWS-WL-NOTIFY] no email for writer={} (skip mail, log only)", writerId);
return false;
}
mailSender.send(email, subject, body);
return true;
}
}

View File

@ -1,438 +0,0 @@
package com.zioinfo.esn.uiws.worklog.service;
import com.zioinfo.esn.uiws.common.UiwsApiException;
import com.zioinfo.esn.uiws.common.UiwsCurrentUser;
import com.zioinfo.esn.uiws.common.UiwsDataScope;
import com.zioinfo.esn.uiws.common.UiwsErrorCode;
import com.zioinfo.esn.uiws.worklog.dto.WorklogDtos.*;
import com.zioinfo.esn.uiws.worklog.mapper.WorklogMapper;
import com.zioinfo.esn.uiws.worklog.model.UiwsWorklog;
import com.zioinfo.esn.uiws.worklog.model.UiwsWorklogCmt;
import com.zioinfo.esn.uiws.worklog.model.UiwsWorklogDtl;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.YearMonth;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* 업무일지(모듈 06) 서비스 원본 com.urp.uiws.worklog.service.WorklogService MyBatis 변환 이식.
*
* silo 단순화(코어 TB_CODE/TB_DEPT 미이식):
* - 근무상태/진행 코드 라벨: 코드값 그대로 노출(공통코드 미이식). 진행 라벨은 ONGOING/DONE 한글 매핑만 내장.
* - 진행상태 집계: ONGOING/DONE 고정 0건 포함 반환(차트 범례 안정).
* - 댓글 권한: 원본 "관할 상무 이상"(부서계층+직급) ERP RBAC MANAGER/ADMIN/CFO 완화 이식.
* - 댓글 알림: NaverWorks(외부) MailSender 폴백(외부 API 금지 준수).
*/
@Service
@RequiredArgsConstructor
public class WorklogService {
private static final String DEFAULT_PROGRESS = "ONGOING";
private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss");
/** 진행 코드 → 라벨(공통코드 미이식 silo 보정). */
private static final Map<String, String> PROGRESS_LABEL = Map.of("ONGOING", "진행중", "DONE", "종료");
private final WorklogMapper mapper;
private final WorklogNotifier notifier;
// ------------------------------------------------------------------ 목록(리스트형)
@Transactional(readOnly = true)
public WorklogPage list(LocalDate fromDate, LocalDate toDate, String writerId, String progressCd, int page, int size) {
LocalDate to = (toDate != null) ? toDate : LocalDate.now();
LocalDate from = (fromDate != null) ? fromDate : to.minusMonths(1);
UiwsDataScope.Scope scope = UiwsDataScope.current();
String wid = blankToNull(writerId);
String pc = blankToNull(progressCd);
long total = mapper.countList(from, to, wid, pc, scope.all(), scope.ownerIds());
List<UiwsWorklog> rows = mapper.searchList(from, to, wid, pc, scope.all(), scope.ownerIds(), size, page * size);
return new WorklogPage(toListDtos(rows), total, page, size, totalPages(total, size));
}
// ------------------------------------------------------------------ 목록(달력형)
@Transactional(readOnly = true)
public List<WorklogCalendarDto> calendar(String yearMonth, String writerId) {
YearMonth ym = parseYearMonth(yearMonth);
LocalDate from = ym.atDay(1);
LocalDate to = ym.atEndOfMonth();
UiwsDataScope.Scope scope = UiwsDataScope.current();
List<UiwsWorklog> rows = mapper.findByMonth(from, to, blankToNull(writerId), scope.all(), scope.ownerIds());
// 공휴일 마스터 미연동 isHoliday=false. 근무상태 라벨은 코드값 노출(공통코드 미이식).
return rows.stream()
.map(w -> new WorklogCalendarDto(w.getWorkDate().toString(), w.getWorklogId(),
w.getWorkStatusCd(), false, null))
.toList();
}
// ------------------------------------------------------------------ 조회 팝업
@Transactional(readOnly = true)
public List<WorklogListDto> search(String keyword, String writerId) {
UiwsDataScope.Scope scope = UiwsDataScope.current();
List<UiwsWorklog> rows = mapper.searchPopup(blankToNull(keyword), blankToNull(writerId), scope.all(), scope.ownerIds());
return toListDtos(rows);
}
// ------------------------------------------------------------------ 대시보드: 진행상태별 집계
@Transactional(readOnly = true)
public List<ProgressSummaryDto> progressSummary(LocalDate fromDate, LocalDate toDate) {
LocalDate to = (toDate != null) ? toDate : LocalDate.now();
LocalDate from = (fromDate != null) ? fromDate : to.minusMonths(1);
UiwsDataScope.Scope scope = UiwsDataScope.current();
Map<String, Long> counts = new LinkedHashMap<>();
for (Map<String, Object> r : mapper.progressSummary(from, to, scope.all(), scope.ownerIds())) {
counts.put(str(r.get("progressCd")), toLong(r.get("cnt")));
}
// 코드 정의 순서(진행중종료) 0건 포함 반환.
List<ProgressSummaryDto> out = new ArrayList<>();
for (String code : List.of("ONGOING", "DONE")) {
out.add(new ProgressSummaryDto(code, PROGRESS_LABEL.getOrDefault(code, code), counts.getOrDefault(code, 0L)));
}
// 정의 코드값도 누락 없이 추가
counts.forEach((k, v) -> {
if (!"ONGOING".equals(k) && !"DONE".equals(k)) {
out.add(new ProgressSummaryDto(k, k, v));
}
});
return out;
}
// ------------------------------------------------------------------ 등록
@Transactional
public WorklogDetailDto create(WorklogSaveDto dto) {
String actor = UiwsCurrentUser.id();
UiwsWorklog w = new UiwsWorklog();
applyHeader(w, dto);
w.setWriterId((dto.writerId() != null && !dto.writerId().isBlank()) ? dto.writerId() : actor);
w.setCreatedBy(actor);
w.setCreatedAt(LocalDateTime.now());
mapper.insertWorklog(w);
List<WorklogDtlDto> toInsert = (dto.details() == null) ? List.of()
: dto.details().stream().filter(d -> !"DEL".equalsIgnoreCase(safeState(d.state()))).toList();
validateNoOverlap(toInsert);
for (WorklogDtlDto d : toInsert) {
mapper.insertDtl(newDtl(w.getWorklogId(), d, actor));
}
return detail(w.getWorklogId());
}
// ------------------------------------------------------------------ 상세
@Transactional(readOnly = true)
public WorklogDetailDto detail(Long worklogId) {
return toDetail(findWorklog(worklogId));
}
// ------------------------------------------------------------------ 수정(시간대별 일괄, _state)
@Transactional
public WorklogDetailDto update(Long worklogId, WorklogSaveDto dto) {
String actor = UiwsCurrentUser.id();
UiwsWorklog w = findWorklog(worklogId);
applyHeader(w, dto);
if (dto.writerId() != null && !dto.writerId().isBlank()) {
w.setWriterId(dto.writerId());
}
w.setUpdatedBy(actor);
w.setUpdatedAt(LocalDateTime.now());
mapper.updateWorklog(w);
List<UiwsWorklogDtl> existing = mapper.findDtlByWorklogId(worklogId);
Map<Long, UiwsWorklogDtl> existingById = existing.stream()
.collect(Collectors.toMap(UiwsWorklogDtl::getDtlId, d -> d));
List<WorklogDtlDto> details = (dto.details() == null) ? List.of() : dto.details();
List<WorklogDtlDto> survivors = new ArrayList<>();
for (WorklogDtlDto d : details) {
String st = safeState(d.state());
if ("DEL".equalsIgnoreCase(st)) {
if (d.dtlId() != null && existingById.containsKey(d.dtlId())) {
mapper.deleteDtlById(d.dtlId());
}
continue;
}
if ("MOD".equalsIgnoreCase(st) && d.dtlId() != null) {
UiwsWorklogDtl tgt = existingById.get(d.dtlId());
if (tgt == null) {
throw new UiwsApiException(UiwsErrorCode.WORKLOG_NOT_FOUND, "수정 대상 상세를 찾을 수 없습니다.");
}
applyDtl(tgt, d);
tgt.setUpdatedBy(actor);
tgt.setUpdatedAt(LocalDateTime.now());
mapper.updateDtl(tgt);
survivors.add(d);
continue;
}
// _state 미표기 + 기존 dtlId 보유 = 미변경 유지행 INSERT 금지
if (d.dtlId() != null && existingById.containsKey(d.dtlId())) {
survivors.add(d);
continue;
}
// ADD 또는 진짜 신규 INSERT
mapper.insertDtl(newDtl(worklogId, d, actor));
survivors.add(d);
}
validateNoOverlap(survivors);
return detail(worklogId);
}
// ------------------------------------------------------------------ 삭제
@Transactional
public void delete(Long worklogId) {
if (!mapper.existsWorklogById(worklogId)) {
throw new UiwsApiException(UiwsErrorCode.WORKLOG_NOT_FOUND);
}
// 상세/댓글은 DB ON DELETE CASCADE. 헤더 삭제로 일괄 제거.
mapper.deleteWorklogById(worklogId);
}
// ------------------------------------------------------------------ 댓글 등록(+알림)
@Transactional
public WorklogCommentDto addComment(Long worklogId, CommentRequest req) {
String actor = UiwsCurrentUser.id();
UiwsWorklog w = findWorklog(worklogId);
assertCanComment();
UiwsWorklogCmt c = new UiwsWorklogCmt();
c.setWorklogId(worklogId);
c.setCmtContent(req.cmtContent());
c.setWriterId(actor);
c.setKakaoSentYn("N");
c.setConfirmYn("N");
c.setCreatedBy(actor);
c.setCreatedAt(LocalDateTime.now());
mapper.insertCmt(c);
// 알림: 일지 작성자에게 메일 폴백(외부 메신저 미사용).
String writerEmail = userEmail(w.getWriterId());
String subject = "[UIWS] 업무일지 새 댓글: " + w.getTitle();
String body = "업무일지에 새 댓글이 등록되었습니다.\n내용: " + req.cmtContent();
boolean sent = notifier.notifyComment(w.getWriterId(), writerEmail, subject, body);
if (sent) {
mapper.updateCmtKakaoSent(c.getCmtId());
c.setKakaoSentYn("Y");
}
String writerNm = userNames(List.of(actor)).getOrDefault(actor, actor);
return new WorklogCommentDto(c.getCmtId(), c.getCmtContent(), writerNm,
fmt(c.getCreatedAt()), c.getKakaoSentYn(), c.getConfirmYn());
}
// ------------------------------------------------------------------ 댓글 확인(작성자 전용)
@Transactional
public void confirmComment(Long cmtId) {
String actor = UiwsCurrentUser.id();
UiwsWorklogCmt c = mapper.findCmtById(cmtId);
if (c == null) {
throw new UiwsApiException(UiwsErrorCode.WORKLOG_NOT_FOUND, "댓글을 찾을 수 없습니다.");
}
UiwsWorklog w = findWorklog(c.getWorklogId());
if (!w.getWriterId().equals(actor)) {
throw new UiwsApiException(UiwsErrorCode.FORBIDDEN, "본인 업무일지의 댓글만 확인 처리할 수 있습니다.");
}
mapper.confirmCmt(cmtId, actor);
}
// ------------------------------------------------------------------ 내가 댓글 작성자 미확인 목록
@Transactional(readOnly = true)
public List<UnconfirmedCommentDto> unconfirmedByMe() {
String me = UiwsCurrentUser.id();
List<UiwsWorklogCmt> rows = mapper.findUnconfirmedByWriter(me);
if (rows.isEmpty()) {
return List.of();
}
List<Long> worklogIds = rows.stream().map(UiwsWorklogCmt::getWorklogId).distinct().toList();
Map<Long, UiwsWorklog> worklogs = mapper.findWorklogsByIds(worklogIds).stream()
.collect(Collectors.toMap(UiwsWorklog::getWorklogId, wl -> wl));
Map<String, String> writerNames = userNames(
worklogs.values().stream().map(UiwsWorklog::getWriterId).toList());
return rows.stream().map(c -> {
UiwsWorklog wl = worklogs.get(c.getWorklogId());
return new UnconfirmedCommentDto(c.getCmtId(), c.getWorklogId(),
wl != null ? wl.getTitle() : null,
wl != null ? writerNames.getOrDefault(wl.getWriterId(), wl.getWriterId()) : null,
c.getCmtContent(), fmt(c.getCreatedAt()));
}).toList();
}
// ================================================================== helpers
/** 댓글 권한(silo 완화): ERP RBAC MANAGER/ADMIN/CFO 만 작성 가능. */
private void assertCanComment() {
if (!UiwsCurrentUser.isManagerOrAbove()) {
throw new UiwsApiException(UiwsErrorCode.FORBIDDEN, "댓글은 관리자/매니저 권한만 작성할 수 있습니다.");
}
}
private List<WorklogListDto> toListDtos(List<UiwsWorklog> rows) {
Map<Long, Long> cmtCounts = commentCounts(rows.stream().map(UiwsWorklog::getWorklogId).toList());
Map<String, String> writerNames = userNames(rows.stream().map(UiwsWorklog::getWriterId).toList());
return rows.stream()
.map(w -> new WorklogListDto(w.getWorklogId(), w.getTitle(),
writerNames.getOrDefault(w.getWriterId(), w.getWriterId()),
w.getWorkDate().toString(), w.getProgressCd(),
cmtCounts.getOrDefault(w.getWorklogId(), 0L)))
.toList();
}
private WorklogDetailDto toDetail(UiwsWorklog w) {
List<UiwsWorklogDtl> dtls = mapper.findDtlByWorklogId(w.getWorklogId());
List<UiwsWorklogCmt> cmts = mapper.findCmtByWorklogId(w.getWorklogId());
List<String> nameIds = new ArrayList<>();
nameIds.add(w.getWriterId());
cmts.forEach(c -> nameIds.add(c.getWriterId()));
Map<String, String> writerNames = userNames(nameIds);
List<WorklogDtlDto> details = dtls.stream().map(WorklogDtlDto::from).toList();
List<WorklogCommentDto> comments = cmts.stream()
.map(c -> new WorklogCommentDto(c.getCmtId(), c.getCmtContent(),
writerNames.getOrDefault(c.getWriterId(), c.getWriterId()),
fmt(c.getCreatedAt()), c.getKakaoSentYn(), c.getConfirmYn()))
.toList();
return new WorklogDetailDto(w.getWorklogId(), w.getTitle(), w.getWriterId(),
writerNames.getOrDefault(w.getWriterId(), w.getWriterId()), w.getWorkDate().toString(),
w.getWorkStatusCd(), w.getProgressCd(), w.getRepeatYn(),
w.getRepeatStartDate() != null ? w.getRepeatStartDate().toString() : null,
w.getRepeatEndDate() != null ? w.getRepeatEndDate().toString() : null,
details, comments);
}
private void applyHeader(UiwsWorklog w, WorklogSaveDto dto) {
w.setTitle(dto.title());
w.setWorkDate(parseDate(dto.workDate()));
w.setWorkStatusCd(dto.workStatusCd());
w.setProgressCd((dto.progressCd() != null && !dto.progressCd().isBlank()) ? dto.progressCd() : DEFAULT_PROGRESS);
w.setRepeatYn((dto.repeatYn() != null && !dto.repeatYn().isBlank()) ? dto.repeatYn() : "N");
w.setRepeatStartDate(parseDateNullable(dto.repeatStartDate()));
w.setRepeatEndDate(parseDateNullable(dto.repeatEndDate()));
}
private UiwsWorklogDtl newDtl(Long worklogId, WorklogDtlDto d, String actor) {
UiwsWorklogDtl e = new UiwsWorklogDtl();
e.setWorklogId(worklogId);
applyDtl(e, d);
e.setCreatedBy(actor);
e.setCreatedAt(LocalDateTime.now());
return e;
}
private void applyDtl(UiwsWorklogDtl e, WorklogDtlDto d) {
if (d.startHour() == null || d.endHour() == null
|| d.startHour() < 0 || d.endHour() > 24 || d.endHour() < d.startHour()) {
throw new UiwsApiException(UiwsErrorCode.WORKLOG_TIME_INVALID);
}
e.setStartHour(d.startHour());
e.setEndHour(d.endHour());
e.setWorkTypeCd(d.workTypeCd());
e.setCompanyId(d.companyId());
e.setWorkContent(d.workContent());
e.setIssueContent(d.issueContent());
e.setSortOrd(d.sortOrd() != null ? d.sortOrd() : 0);
}
private void validateNoOverlap(List<WorklogDtlDto> details) {
List<WorklogDtlDto> rows = details.stream()
.filter(d -> d.startHour() != null && d.endHour() != null)
.sorted((a, b) -> Integer.compare(a.startHour(), b.startHour()))
.toList();
for (int i = 1; i < rows.size(); i++) {
WorklogDtlDto prev = rows.get(i - 1);
WorklogDtlDto cur = rows.get(i);
if (cur.startHour() < prev.endHour()) {
throw new UiwsApiException(UiwsErrorCode.WORKLOG_TIME_OVERLAP);
}
}
}
private UiwsWorklog findWorklog(Long id) {
UiwsWorklog w = mapper.findWorklogById(id);
if (w == null) {
throw new UiwsApiException(UiwsErrorCode.WORKLOG_NOT_FOUND);
}
return w;
}
private Map<Long, Long> commentCounts(List<Long> worklogIds) {
if (worklogIds == null || worklogIds.isEmpty()) {
return Map.of();
}
Map<Long, Long> result = new LinkedHashMap<>();
for (Map<String, Object> cc : mapper.countCommentsByWorklogIds(worklogIds)) {
result.put(toLong(cc.get("worklogId")), toLong(cc.get("cnt")));
}
return result;
}
private Map<String, String> userNames(List<String> userIds) {
List<String> ids = userIds.stream().filter(s -> s != null && !s.isBlank()).distinct().toList();
if (ids.isEmpty()) {
return Map.of();
}
return mapper.findUserNames(ids).stream()
.collect(Collectors.toMap(r -> r.get("username"), r -> r.get("fullName"), (a, b) -> a));
}
/** writerId(username) → email (댓글 알림 메일 발송용). 미보유 시 null. */
private String userEmail(String writerId) {
if (writerId == null || writerId.isBlank()) {
return null;
}
return mapper.findUserEmail(writerId);
}
private static int totalPages(long total, int size) {
return size <= 0 ? 0 : (int) ((total + size - 1) / size);
}
private static String blankToNull(String s) {
return (s == null || s.isBlank()) ? null : s;
}
private static String safeState(String s) {
return s == null ? "" : s.trim();
}
private static String str(Object o) {
return o == null ? "" : o.toString();
}
private static long toLong(Object o) {
if (o == null) {
return 0L;
}
return (o instanceof Number n) ? n.longValue() : Long.parseLong(o.toString());
}
private static String fmt(LocalDateTime dt) {
return dt != null ? dt.format(DT) : null;
}
private static LocalDate parseDate(String s) {
try {
return LocalDate.parse(s);
} catch (Exception e) {
throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "날짜 형식이 올바르지 않습니다(yyyy-MM-dd).");
}
}
private static LocalDate parseDateNullable(String s) {
return (s == null || s.isBlank()) ? null : parseDate(s);
}
private static YearMonth parseYearMonth(String s) {
try {
if (s == null || s.isBlank()) {
return YearMonth.now();
}
return YearMonth.parse(s);
} catch (Exception e) {
throw new UiwsApiException(UiwsErrorCode.INVALID_REQUEST, "yearMonth 형식이 올바르지 않습니다(yyyy-MM).");
}
}
}

View File

@ -1,5 +1,5 @@
server: server:
port: 8016 port: 8015
spring: spring:
application: application:
@ -12,16 +12,6 @@ spring:
hikari: hikari:
maximum-pool-size: 3 maximum-pool-size: 3
connection-timeout: 30000 connection-timeout: 30000
# UIWS 이식: 부팅 시 schema.sql + 91_uiws_port.sql 멱등 적용
# (둘 다 IF NOT EXISTS / ADD COLUMN IF NOT EXISTS / ON CONFLICT DO NOTHING — 재실행 안전).
# 기존 deploy 의 psql -f schema.sql 와 중복돼도 멱등. 91_uiws_port 는 deploy 미적용분(uiws 테이블/2FA 컬럼) 보강.
sql:
init:
mode: always
continue-on-error: true
schema-locations:
- classpath:db/schema.sql
- classpath:db/91_uiws_port.sql
web: web:
resources: resources:
static-locations: classpath:/static/ static-locations: classpath:/static/
@ -29,8 +19,7 @@ spring:
throw-exception-if-no-handler-found: true throw-exception-if-no-handler-found: true
mybatis: mybatis:
# UIWS 이식: mapper/uiws/*.xml 포함을 위해 재귀 글로브(**)로 확장(기존 mapper/*.xml 포함). mapper-locations: classpath:mapper/*.xml
mapper-locations: classpath:mapper/**/*.xml
configuration: configuration:
map-underscore-to-camel-case: true map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl

View File

@ -1,243 +0,0 @@
-- ============================================================================
-- UIWS 업무 테이블 이식 (ESN) — 2026-06-20, uiws-schema-porter
-- 계획서: .claude/agents/_workspace/uiws_port_plan.md (schema-porter 트랙 6.1)
-- 원본: workspace/uiws/db/{03_worklog,04_schedule,05_message,02_core(2FA)}.sql
--
-- 네임스페이스 격리: UIWS TB_* → 소문자 tb_uiws_ 프리픽스 (기존 tb_audit_log 등과 충돌 회피).
-- 멱등: 전부 CREATE TABLE IF NOT EXISTS / ADD COLUMN IF NOT EXISTS / ON CONFLICT DO NOTHING.
-- mode:always 재실행 시 완전 멱등(기존 schema.sql·시드 무영향).
-- FK 정책: tb_uiws_* 내부 참조만 물리 FK. 외부 user/code/company/dept FK는 논리참조로 완화
-- (UIWS는 VARCHAR USER_ID 키 / ESN esn_user PK는 BIGSERIAL — 키 체계 불일치 → 컬럼만 유지).
-- 코드값(WORK_STATUS_CD/WORK_TYPE_CD/RCV_TYPE 등): TB_CODE 미이식 → 일반 컬럼 + CHECK만 유지.
-- ============================================================================
SET client_encoding = 'UTF8';
-- ───────────────────────────────────────────────────────────────────────────
-- [worklog] tb_uiws_worklog — 업무일지 헤더 (원본 TB_WORKLOG)
-- ───────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS tb_uiws_worklog (
worklog_id BIGINT GENERATED ALWAYS AS IDENTITY,
title VARCHAR(200) NOT NULL,
writer_id VARCHAR(20) NOT NULL, -- 논리참조(UIWS USER_ID)
work_date DATE NOT NULL,
work_status_cd VARCHAR(30) NOT NULL, -- WORK_STATUS 코드값(논리)
progress_cd VARCHAR(30) NOT NULL DEFAULT 'ONGOING',
repeat_yn CHAR(1) NOT NULL DEFAULT 'N',
repeat_start_date DATE,
repeat_end_date DATE,
created_by VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_by VARCHAR(20),
updated_at TIMESTAMP,
CONSTRAINT pk_tb_uiws_worklog PRIMARY KEY (worklog_id),
CONSTRAINT ck_uiws_worklog_progress CHECK (progress_cd IN ('ONGOING','DONE')),
CONSTRAINT ck_uiws_worklog_repeat_yn CHECK (repeat_yn IN ('Y','N'))
);
COMMENT ON TABLE tb_uiws_worklog IS 'UIWS 이식: 업무일지 헤더 (근무일자/근무상태/반복)';
-- tb_uiws_worklog_dtl — 시간대별 상세 (통계 집계 원천, 원본 TB_WORKLOG_DTL)
CREATE TABLE IF NOT EXISTS tb_uiws_worklog_dtl (
dtl_id BIGINT GENERATED ALWAYS AS IDENTITY,
worklog_id BIGINT NOT NULL,
start_hour INT NOT NULL,
end_hour INT NOT NULL,
work_type_cd VARCHAR(30) NOT NULL, -- WORK_TYPE 코드값(논리)
company_id VARCHAR(20) NOT NULL, -- 논리참조(UIWS COMPANY_ID)
work_content TEXT,
issue_content TEXT,
sort_ord INT DEFAULT 0,
created_by VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_by VARCHAR(20),
updated_at TIMESTAMP,
CONSTRAINT pk_tb_uiws_worklog_dtl PRIMARY KEY (dtl_id),
CONSTRAINT fk_uiws_dtl_worklog FOREIGN KEY (worklog_id)
REFERENCES tb_uiws_worklog (worklog_id) ON DELETE CASCADE,
CONSTRAINT ck_uiws_dtl_start_hour CHECK (start_hour BETWEEN 0 AND 24),
CONSTRAINT ck_uiws_dtl_end_hour CHECK (end_hour BETWEEN 0 AND 24),
CONSTRAINT ck_uiws_dtl_hour_order CHECK (end_hour >= start_hour)
);
COMMENT ON TABLE tb_uiws_worklog_dtl IS 'UIWS 이식: 업무일지 시간대별 상세 (헤더 삭제 시 CASCADE)';
-- tb_uiws_worklog_cmt — 댓글 (원본 TB_WORKLOG_CMT)
CREATE TABLE IF NOT EXISTS tb_uiws_worklog_cmt (
cmt_id BIGINT GENERATED ALWAYS AS IDENTITY,
worklog_id BIGINT NOT NULL,
cmt_content TEXT NOT NULL,
writer_id VARCHAR(20) NOT NULL, -- 논리참조
kakao_sent_yn CHAR(1) NOT NULL DEFAULT 'N',
confirm_yn CHAR(1) NOT NULL DEFAULT 'N',
created_by VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_by VARCHAR(20),
updated_at TIMESTAMP,
CONSTRAINT pk_tb_uiws_worklog_cmt PRIMARY KEY (cmt_id),
CONSTRAINT fk_uiws_cmt_worklog FOREIGN KEY (worklog_id)
REFERENCES tb_uiws_worklog (worklog_id) ON DELETE CASCADE,
CONSTRAINT ck_uiws_cmt_confirm_yn CHECK (confirm_yn IN ('Y','N')),
CONSTRAINT ck_uiws_cmt_kakao_yn CHECK (kakao_sent_yn IN ('Y','N'))
);
COMMENT ON TABLE tb_uiws_worklog_cmt IS 'UIWS 이식: 업무일지 댓글 (등록 시 카카오 알림톡)';
CREATE INDEX IF NOT EXISTS ix_uiws_worklog_date_writer ON tb_uiws_worklog (work_date, writer_id, work_status_cd);
CREATE INDEX IF NOT EXISTS ix_uiws_worklog_writer ON tb_uiws_worklog (writer_id);
CREATE INDEX IF NOT EXISTS ix_uiws_dtl_type_company ON tb_uiws_worklog_dtl (work_type_cd, company_id);
CREATE INDEX IF NOT EXISTS ix_uiws_dtl_worklog ON tb_uiws_worklog_dtl (worklog_id);
CREATE INDEX IF NOT EXISTS ix_uiws_dtl_company ON tb_uiws_worklog_dtl (company_id);
CREATE INDEX IF NOT EXISTS ix_uiws_cmt_worklog ON tb_uiws_worklog_cmt (worklog_id);
-- ───────────────────────────────────────────────────────────────────────────
-- [schedule] tb_uiws_schedule — 일정 개인/부서 (원본 TB_SCHEDULE)
-- ───────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS tb_uiws_schedule (
schedule_id BIGINT GENERATED ALWAYS AS IDENTITY,
sche_type VARCHAR(10) NOT NULL,
title VARCHAR(200) NOT NULL,
sche_gubun_cd VARCHAR(30),
importance_cd VARCHAR(30),
start_dt TIMESTAMP NOT NULL,
end_dt TIMESTAMP NOT NULL,
content TEXT,
owner_id VARCHAR(20) NOT NULL, -- 논리참조
dept_id VARCHAR(20), -- 논리참조
charger_id VARCHAR(20), -- 논리참조
created_by VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_by VARCHAR(20),
updated_at TIMESTAMP,
CONSTRAINT pk_tb_uiws_schedule PRIMARY KEY (schedule_id),
CONSTRAINT ck_uiws_sche_type CHECK (sche_type IN ('PERSONAL','DEPT')),
CONSTRAINT ck_uiws_sche_dt_order CHECK (end_dt >= start_dt)
);
COMMENT ON TABLE tb_uiws_schedule IS 'UIWS 이식: 일정(개인 PERSONAL / 부서 DEPT)';
-- tb_uiws_diary — 일지(일정 연계 선택, 원본 TB_DIARY)
CREATE TABLE IF NOT EXISTS tb_uiws_diary (
diary_id BIGINT GENERATED ALWAYS AS IDENTITY,
title VARCHAR(200) NOT NULL,
content TEXT,
schedule_id BIGINT,
writer_id VARCHAR(20) NOT NULL, -- 논리참조
diary_date DATE,
created_by VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_by VARCHAR(20),
updated_at TIMESTAMP,
CONSTRAINT pk_tb_uiws_diary PRIMARY KEY (diary_id),
CONSTRAINT fk_uiws_diary_sche FOREIGN KEY (schedule_id)
REFERENCES tb_uiws_schedule (schedule_id) ON DELETE SET NULL
);
COMMENT ON TABLE tb_uiws_diary IS 'UIWS 이식: 일지 (일정 연계 선택, 일정 삭제 시 연계 해제)';
-- tb_uiws_attach — 첨부파일 폴리모픽 (원본 TB_ATTACH, 물리 FK 미적용)
CREATE TABLE IF NOT EXISTS tb_uiws_attach (
attach_id BIGINT GENERATED ALWAYS AS IDENTITY,
ref_type VARCHAR(20) NOT NULL,
ref_id BIGINT NOT NULL,
file_nm VARCHAR(255) NOT NULL,
file_path VARCHAR(500) NOT NULL,
file_size BIGINT,
created_by VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_by VARCHAR(20),
updated_at TIMESTAMP,
CONSTRAINT pk_tb_uiws_attach PRIMARY KEY (attach_id),
CONSTRAINT ck_uiws_attach_ref_type CHECK (ref_type IN ('SCHEDULE','DIARY')),
CONSTRAINT ck_uiws_attach_size CHECK (file_size IS NULL OR file_size >= 0)
);
COMMENT ON TABLE tb_uiws_attach IS 'UIWS 이식: 첨부파일 (폴리모픽 REF_TYPE=SCHEDULE/DIARY)';
CREATE INDEX IF NOT EXISTS ix_uiws_sche_dt_range ON tb_uiws_schedule (start_dt, end_dt, sche_type);
CREATE INDEX IF NOT EXISTS ix_uiws_sche_owner ON tb_uiws_schedule (owner_id);
CREATE INDEX IF NOT EXISTS ix_uiws_sche_dept ON tb_uiws_schedule (dept_id);
CREATE INDEX IF NOT EXISTS ix_uiws_diary_sche ON tb_uiws_diary (schedule_id);
CREATE INDEX IF NOT EXISTS ix_uiws_diary_writer ON tb_uiws_diary (writer_id, diary_date);
CREATE INDEX IF NOT EXISTS ix_uiws_attach_ref ON tb_uiws_attach (ref_type, ref_id);
-- ───────────────────────────────────────────────────────────────────────────
-- [message] tb_uiws_message — 쪽지 헤더 (원본 TB_MESSAGE)
-- REF_WORKLOG_ID → tb_uiws_worklog (내부 FK 유지), REPLY_TO_ID → 자기참조.
-- ───────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS tb_uiws_message (
message_id BIGINT GENERATED ALWAYS AS IDENTITY,
sender_id VARCHAR(20) NOT NULL, -- 논리참조
title VARCHAR(200) NOT NULL,
content TEXT NOT NULL,
ref_worklog_id BIGINT,
reply_to_id BIGINT,
sent_at TIMESTAMP NOT NULL DEFAULT now(),
sender_del_yn CHAR(1) NOT NULL DEFAULT 'N',
created_by VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_by VARCHAR(20),
updated_at TIMESTAMP,
CONSTRAINT pk_tb_uiws_message PRIMARY KEY (message_id),
CONSTRAINT fk_uiws_msg_worklog FOREIGN KEY (ref_worklog_id)
REFERENCES tb_uiws_worklog (worklog_id) ON DELETE SET NULL,
CONSTRAINT fk_uiws_msg_reply FOREIGN KEY (reply_to_id)
REFERENCES tb_uiws_message (message_id),
CONSTRAINT ck_uiws_msg_sender_del_yn CHECK (sender_del_yn IN ('Y','N'))
);
COMMENT ON TABLE tb_uiws_message IS 'UIWS 이식: 쪽지 헤더 (참조 업무일지/답장 원본 자기참조)';
-- tb_uiws_message_rcv — 수신자 (원본 TB_MESSAGE_RCV)
CREATE TABLE IF NOT EXISTS tb_uiws_message_rcv (
rcv_id BIGINT GENERATED ALWAYS AS IDENTITY,
message_id BIGINT NOT NULL,
receiver_id VARCHAR(20) NOT NULL, -- 논리참조
rcv_type VARCHAR(10) NOT NULL, -- MSG_RCV_TYPE 코드값(논리)
read_yn CHAR(1) NOT NULL DEFAULT 'N',
read_at TIMESTAMP,
receiver_del_yn CHAR(1) NOT NULL DEFAULT 'N',
created_by VARCHAR(20) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_by VARCHAR(20),
updated_at TIMESTAMP,
CONSTRAINT pk_tb_uiws_message_rcv PRIMARY KEY (rcv_id),
CONSTRAINT fk_uiws_rcv_message FOREIGN KEY (message_id)
REFERENCES tb_uiws_message (message_id) ON DELETE CASCADE,
CONSTRAINT uq_uiws_rcv_msg_receiver UNIQUE (message_id, receiver_id),
CONSTRAINT ck_uiws_rcv_type CHECK (rcv_type IN ('RECV','REF')),
CONSTRAINT ck_uiws_rcv_read_yn CHECK (read_yn IN ('Y','N')),
CONSTRAINT ck_uiws_rcv_del_yn CHECK (receiver_del_yn IN ('Y','N'))
);
COMMENT ON TABLE tb_uiws_message_rcv IS 'UIWS 이식: 쪽지 수신자(수신 RECV/참조 REF, 개봉여부)';
CREATE INDEX IF NOT EXISTS ix_uiws_rcv_receiver_read ON tb_uiws_message_rcv (receiver_id, read_yn);
CREATE INDEX IF NOT EXISTS ix_uiws_rcv_message ON tb_uiws_message_rcv (message_id);
CREATE INDEX IF NOT EXISTS ix_uiws_msg_sender ON tb_uiws_message (sender_id, sent_at);
-- ───────────────────────────────────────────────────────────────────────────
-- [2FA] tb_uiws_login_verify — 로그인 2차 검증 코드 (원본 TB_LOGIN_VERIFY)
-- USER_ID 는 논리참조(외부 user FK 미적용 — 키 체계 불일치).
-- ───────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS tb_uiws_login_verify (
verify_id BIGINT GENERATED ALWAYS AS IDENTITY,
user_id VARCHAR(50) NOT NULL, -- ESN username 논리참조
verify_method VARCHAR(20) NOT NULL DEFAULT 'EMAIL',
verify_code VARCHAR(10) NOT NULL,
expire_at TIMESTAMP NOT NULL,
verified_yn CHAR(1) NOT NULL DEFAULT 'N',
created_by VARCHAR(20) NOT NULL DEFAULT 'SYSTEM',
created_at TIMESTAMP NOT NULL DEFAULT now(),
updated_by VARCHAR(20),
updated_at TIMESTAMP,
CONSTRAINT pk_tb_uiws_login_verify PRIMARY KEY (verify_id),
CONSTRAINT ck_uiws_verify_yn CHECK (verified_yn IN ('Y','N')),
CONSTRAINT ck_uiws_verify_method CHECK (verify_method IN ('EMAIL','OTP'))
);
COMMENT ON TABLE tb_uiws_login_verify IS 'UIWS 이식(2FA): 로그인 2차 검증 코드(이메일/OTP, 만료)';
CREATE INDEX IF NOT EXISTS ix_uiws_verify_user ON tb_uiws_login_verify (user_id, verified_yn);
-- ───────────────────────────────────────────────────────────────────────────
-- [2FA] 기존 user 테이블 컬럼 보강 (DROP/재정의 금지 — ADD COLUMN IF NOT EXISTS 멱등)
-- 이메일 인증코드·만료시각, 실패 카운트(기본 0), 잠금(기본 false), OTP 시크릿.
-- ───────────────────────────────────────────────────────────────────────────
ALTER TABLE esn_user ADD COLUMN IF NOT EXISTS email_verify_code VARCHAR(10);
ALTER TABLE esn_user ADD COLUMN IF NOT EXISTS email_verify_expire TIMESTAMP;
ALTER TABLE esn_user ADD COLUMN IF NOT EXISTS login_fail_count INT DEFAULT 0;
ALTER TABLE esn_user ADD COLUMN IF NOT EXISTS locked BOOLEAN DEFAULT false;
ALTER TABLE esn_user ADD COLUMN IF NOT EXISTS otp_secret VARCHAR(255);
-- end 91_uiws_port.sql

View File

@ -14,18 +14,11 @@
<result property="active" column="is_active"/> <result property="active" column="is_active"/>
<result property="lastLoginAt" column="last_login_at"/> <result property="lastLoginAt" column="last_login_at"/>
<result property="createdAt" column="created_at"/> <result property="createdAt" column="created_at"/>
<!-- UIWS 2FA 이식 컬럼 (91_uiws_port.sql ALTER). -->
<result property="emailVerifyCode" column="email_verify_code"/>
<result property="emailVerifyExpire" column="email_verify_expire"/>
<result property="loginFailCount" column="login_fail_count"/>
<result property="locked" column="locked"/>
<result property="otpSecret" column="otp_secret"/>
</resultMap> </resultMap>
<select id="findByUsername" resultMap="userMap"> <select id="findByUsername" resultMap="userMap">
SELECT id, tenant_code, username, password_hash, role, email, phone, SELECT id, tenant_code, username, password_hash, role, email, phone,
is_active, last_login_at, created_at, is_active, last_login_at, created_at
email_verify_code, email_verify_expire, login_fail_count, locked, otp_secret
FROM esn_user FROM esn_user
WHERE username = #{username} WHERE username = #{username}
</select> </select>
@ -34,31 +27,4 @@
UPDATE esn_user SET last_login_at = NOW() WHERE username = #{username} UPDATE esn_user SET last_login_at = NOW() WHERE username = #{username}
</update> </update>
<!-- ── UIWS 2FA 이식: 실패 카운트/잠금/인증코드 갱신 (멱등 UPDATE) ─────────────── -->
<update id="resetLoginFail">
UPDATE esn_user SET login_fail_count = 0 WHERE username = #{username}
</update>
<update id="incrementLoginFail">
UPDATE esn_user
SET login_fail_count = COALESCE(login_fail_count, 0) + 1,
locked = (COALESCE(login_fail_count, 0) + 1 >= #{maxFail})
WHERE username = #{username}
</update>
<update id="saveEmailCode">
UPDATE esn_user
SET email_verify_code = #{code}, email_verify_expire = #{expire}, login_fail_count = 0
WHERE username = #{username}
</update>
<update id="clearEmailCode">
UPDATE esn_user SET email_verify_code = NULL, email_verify_expire = NULL WHERE username = #{username}
</update>
<update id="unlock">
UPDATE esn_user SET locked = false, login_fail_count = 0 WHERE username = #{username}
</update>
</mapper> </mapper>

View File

@ -1,16 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!-- UIWS 이식(2FA): 로그인 2차 검증 이력 (tb_uiws_login_verify). -->
<mapper namespace="com.zioinfo.esn.uiws.auth.LoginVerifyMapper">
<insert id="insert" useGeneratedKeys="true" keyProperty="verifyId" keyColumn="verify_id"
parameterType="com.zioinfo.esn.uiws.auth.UiwsLoginVerify">
INSERT INTO tb_uiws_login_verify
(user_id, verify_method, verify_code, expire_at, verified_yn, created_by, created_at)
VALUES
(#{userId}, #{verifyMethod}, #{verifyCode}, #{expireAt}, #{verifiedYn}, #{createdBy}, #{createdAt})
</insert>
</mapper>

View File

@ -1,137 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!-- UIWS 이식: 쪽지(tb_uiws_message / tb_uiws_message_rcv). -->
<mapper namespace="com.zioinfo.esn.uiws.message.mapper.MessageMapper">
<!-- ============================ message 헤더 ============================ -->
<insert id="insertMessage" useGeneratedKeys="true" keyProperty="messageId" keyColumn="message_id"
parameterType="com.zioinfo.esn.uiws.message.model.UiwsMessage">
INSERT INTO tb_uiws_message
(sender_id, title, content, ref_worklog_id, reply_to_id, sent_at, sender_del_yn, created_by, created_at)
VALUES
(#{senderId}, #{title}, #{content}, #{refWorklogId}, #{replyToId}, #{sentAt}, #{senderDelYn}, #{createdBy}, #{createdAt})
</insert>
<select id="findMessageById" resultType="com.zioinfo.esn.uiws.message.model.UiwsMessage">
SELECT * FROM tb_uiws_message WHERE message_id = #{messageId}
</select>
<select id="existsMessageById" resultType="boolean">
SELECT EXISTS(SELECT 1 FROM tb_uiws_message WHERE message_id = #{messageId})
</select>
<select id="searchSent" resultType="com.zioinfo.esn.uiws.message.model.UiwsMessage">
SELECT * FROM tb_uiws_message
WHERE sender_id = #{senderId}
AND sender_del_yn = 'N'
AND sent_at BETWEEN #{from} AND #{to}
<if test="titleKeyword != null">
AND title LIKE '%' || #{titleKeyword} || '%'
</if>
ORDER BY sent_at DESC, message_id DESC
LIMIT #{limit} OFFSET #{offset}
</select>
<select id="countSent" resultType="long">
SELECT COUNT(*) FROM tb_uiws_message
WHERE sender_id = #{senderId}
AND sender_del_yn = 'N'
AND sent_at BETWEEN #{from} AND #{to}
<if test="titleKeyword != null">
AND title LIKE '%' || #{titleKeyword} || '%'
</if>
</select>
<update id="softDeleteSent">
UPDATE tb_uiws_message
SET sender_del_yn = 'Y', updated_by = #{actor}, updated_at = now()
WHERE sender_id = #{senderId}
AND message_id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
</update>
<!-- ============================ 수신자 ============================ -->
<insert id="insertRcv" useGeneratedKeys="true" keyProperty="rcvId" keyColumn="rcv_id"
parameterType="com.zioinfo.esn.uiws.message.model.UiwsMessageRcv">
INSERT INTO tb_uiws_message_rcv
(message_id, receiver_id, rcv_type, read_yn, receiver_del_yn, created_by, created_at)
VALUES
(#{messageId}, #{receiverId}, #{rcvType}, #{readYn}, #{receiverDelYn}, #{createdBy}, #{createdAt})
</insert>
<select id="findRcvByMessageId" resultType="com.zioinfo.esn.uiws.message.model.UiwsMessageRcv">
SELECT * FROM tb_uiws_message_rcv WHERE message_id = #{messageId} ORDER BY rcv_id ASC
</select>
<select id="findRcvByMessageIds" resultType="com.zioinfo.esn.uiws.message.model.UiwsMessageRcv">
SELECT * FROM tb_uiws_message_rcv
WHERE message_id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
ORDER BY rcv_id ASC
</select>
<select id="findRcvByMessageAndReceiver" resultType="com.zioinfo.esn.uiws.message.model.UiwsMessageRcv">
SELECT * FROM tb_uiws_message_rcv
WHERE message_id = #{messageId} AND receiver_id = #{receiverId}
</select>
<select id="countUnread" resultType="long">
SELECT COUNT(*) FROM tb_uiws_message_rcv
WHERE receiver_id = #{receiverId} AND read_yn = 'N' AND receiver_del_yn = 'N'
</select>
<update id="markRead">
UPDATE tb_uiws_message_rcv
SET read_yn = 'Y', read_at = #{readAt}, updated_by = #{actor}, updated_at = now()
WHERE rcv_id = #{rcvId}
</update>
<update id="softDeleteReceived">
UPDATE tb_uiws_message_rcv
SET receiver_del_yn = 'Y', updated_by = #{actor}, updated_at = now()
WHERE receiver_id = #{receiverId}
AND message_id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
</update>
<select id="searchReceived" resultType="map">
SELECT m.message_id AS "messageId",
m.title AS "title",
m.sender_id AS "senderId",
m.sent_at AS "sentAt",
r.read_yn AS "readYn"
FROM tb_uiws_message_rcv r
JOIN tb_uiws_message m ON m.message_id = r.message_id
WHERE r.receiver_id = #{receiverId}
AND r.receiver_del_yn = 'N'
AND m.sent_at BETWEEN #{from} AND #{to}
<if test="titleKeyword != null">
AND m.title LIKE '%' || #{titleKeyword} || '%'
</if>
ORDER BY m.sent_at DESC, m.message_id DESC
LIMIT #{limit} OFFSET #{offset}
</select>
<select id="countReceived" resultType="long">
SELECT COUNT(*)
FROM tb_uiws_message_rcv r
JOIN tb_uiws_message m ON m.message_id = r.message_id
WHERE r.receiver_id = #{receiverId}
AND r.receiver_del_yn = 'N'
AND m.sent_at BETWEEN #{from} AND #{to}
<if test="titleKeyword != null">
AND m.title LIKE '%' || #{titleKeyword} || '%'
</if>
</select>
<!-- ============================ 사용자명 라벨(코어 user 재사용) ============================ -->
<select id="findUserNames" resultType="map">
SELECT username AS "username", username AS "fullName"
FROM esn_user
WHERE username IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
</select>
</mapper>

View File

@ -1,168 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!-- UIWS 이식: 일정/일지/첨부 (tb_uiws_schedule / tb_uiws_diary / tb_uiws_attach). -->
<mapper namespace="com.zioinfo.esn.uiws.schedule.mapper.ScheduleMapper">
<!-- ===================== schedule ===================== -->
<insert id="insertSchedule" useGeneratedKeys="true" keyProperty="scheduleId" keyColumn="schedule_id"
parameterType="com.zioinfo.esn.uiws.schedule.model.UiwsSchedule">
INSERT INTO tb_uiws_schedule
(sche_type, title, sche_gubun_cd, importance_cd, start_dt, end_dt, content,
owner_id, dept_id, charger_id, created_by, created_at)
VALUES
(#{scheType}, #{title}, #{scheGubunCd}, #{importanceCd}, #{startDt}, #{endDt}, #{content},
#{ownerId}, #{deptId}, #{chargerId}, #{createdBy}, #{createdAt})
</insert>
<update id="updateSchedule" parameterType="com.zioinfo.esn.uiws.schedule.model.UiwsSchedule">
UPDATE tb_uiws_schedule SET
sche_type = #{scheType}, title = #{title}, sche_gubun_cd = #{scheGubunCd},
importance_cd = #{importanceCd}, start_dt = #{startDt}, end_dt = #{endDt},
content = #{content}, dept_id = #{deptId}, charger_id = #{chargerId},
updated_by = #{updatedBy}, updated_at = #{updatedAt}
WHERE schedule_id = #{scheduleId}
</update>
<select id="findScheduleById" resultType="com.zioinfo.esn.uiws.schedule.model.UiwsSchedule">
SELECT * FROM tb_uiws_schedule WHERE schedule_id = #{scheduleId}
</select>
<select id="existsScheduleById" resultType="boolean">
SELECT EXISTS(SELECT 1 FROM tb_uiws_schedule WHERE schedule_id = #{scheduleId})
</select>
<delete id="deleteScheduleById">
DELETE FROM tb_uiws_schedule WHERE schedule_id = #{scheduleId}
</delete>
<!-- 기간 겹침: start_dt &lt; to(배타 상한) AND end_dt &gt;= from -->
<select id="findInRange" resultType="com.zioinfo.esn.uiws.schedule.model.UiwsSchedule">
SELECT * FROM tb_uiws_schedule
WHERE sche_type = #{scheType}
AND start_dt &lt; #{to}
AND end_dt &gt;= #{from}
<if test="ownerId != null">AND owner_id = #{ownerId}</if>
<if test="deptId != null">AND dept_id = #{deptId}</if>
ORDER BY start_dt ASC, schedule_id ASC
</select>
<sql id="allWhere">
WHERE start_dt &lt; #{to}
AND end_dt &gt;= #{from}
<if test="scheType != null">AND sche_type = #{scheType}</if>
<if test="!scopeAll">
AND owner_id IN
<foreach collection="ownerIds" item="oid" open="(" separator="," close=")">#{oid}</foreach>
</if>
</sql>
<select id="searchAll" resultType="com.zioinfo.esn.uiws.schedule.model.UiwsSchedule">
SELECT * FROM tb_uiws_schedule
<include refid="allWhere"/>
ORDER BY start_dt DESC, schedule_id DESC
LIMIT #{limit} OFFSET #{offset}
</select>
<select id="countAll" resultType="long">
SELECT COUNT(*) FROM tb_uiws_schedule
<include refid="allWhere"/>
</select>
<select id="searchPopup" resultType="com.zioinfo.esn.uiws.schedule.model.UiwsSchedule">
SELECT * FROM tb_uiws_schedule
WHERE 1=1
<if test="keyword != null">
AND (title LIKE '%' || #{keyword} || '%' OR content LIKE '%' || #{keyword} || '%')
</if>
<if test="!scopeAll">
AND owner_id IN
<foreach collection="ownerIds" item="oid" open="(" separator="," close=")">#{oid}</foreach>
</if>
ORDER BY start_dt DESC, schedule_id DESC
LIMIT 50
</select>
<!-- ===================== diary ===================== -->
<insert id="insertDiary" useGeneratedKeys="true" keyProperty="diaryId" keyColumn="diary_id"
parameterType="com.zioinfo.esn.uiws.schedule.model.UiwsDiary">
INSERT INTO tb_uiws_diary
(title, content, schedule_id, writer_id, diary_date, created_by, created_at)
VALUES
(#{title}, #{content}, #{scheduleId}, #{writerId}, #{diaryDate}, #{createdBy}, #{createdAt})
</insert>
<update id="updateDiary" parameterType="com.zioinfo.esn.uiws.schedule.model.UiwsDiary">
UPDATE tb_uiws_diary SET
title = #{title}, content = #{content}, schedule_id = #{scheduleId}, diary_date = #{diaryDate},
updated_by = #{updatedBy}, updated_at = #{updatedAt}
WHERE diary_id = #{diaryId}
</update>
<select id="findDiaryById" resultType="com.zioinfo.esn.uiws.schedule.model.UiwsDiary">
SELECT * FROM tb_uiws_diary WHERE diary_id = #{diaryId}
</select>
<select id="existsDiaryById" resultType="boolean">
SELECT EXISTS(SELECT 1 FROM tb_uiws_diary WHERE diary_id = #{diaryId})
</select>
<delete id="deleteDiaryById">
DELETE FROM tb_uiws_diary WHERE diary_id = #{diaryId}
</delete>
<select id="searchDiary" resultType="com.zioinfo.esn.uiws.schedule.model.UiwsDiary">
SELECT * FROM tb_uiws_diary
WHERE (diary_date IS NULL OR diary_date BETWEEN #{from} AND #{to})
ORDER BY COALESCE(diary_date, created_at::date) DESC, diary_id DESC
LIMIT #{limit} OFFSET #{offset}
</select>
<select id="countDiary" resultType="long">
SELECT COUNT(*) FROM tb_uiws_diary
WHERE (diary_date IS NULL OR diary_date BETWEEN #{from} AND #{to})
</select>
<!-- ===================== attach ===================== -->
<insert id="insertAttach" useGeneratedKeys="true" keyProperty="attachId" keyColumn="attach_id"
parameterType="com.zioinfo.esn.uiws.schedule.model.UiwsAttach">
INSERT INTO tb_uiws_attach
(ref_type, ref_id, file_nm, file_path, file_size, created_by, created_at)
VALUES
(#{refType}, #{refId}, #{fileNm}, #{filePath}, #{fileSize}, #{createdBy}, #{createdAt})
</insert>
<select id="findAttachById" resultType="com.zioinfo.esn.uiws.schedule.model.UiwsAttach">
SELECT * FROM tb_uiws_attach WHERE attach_id = #{attachId}
</select>
<update id="updateAttachRef">
UPDATE tb_uiws_attach
SET ref_type = #{refType}, ref_id = #{refId}, updated_by = #{actor}, updated_at = now()
WHERE attach_id = #{attachId}
</update>
<select id="findAttachByRef" resultType="com.zioinfo.esn.uiws.schedule.model.UiwsAttach">
SELECT * FROM tb_uiws_attach
WHERE ref_type = #{refType} AND ref_id = #{refId}
ORDER BY attach_id ASC
</select>
<delete id="deleteAttachById">
DELETE FROM tb_uiws_attach WHERE attach_id = #{attachId}
</delete>
<delete id="deleteAttachByRef">
DELETE FROM tb_uiws_attach WHERE ref_type = #{refType} AND ref_id = #{refId}
</delete>
<!-- ===================== 사용자명 라벨 ===================== -->
<select id="findUserNames" resultType="map">
SELECT username AS "username", username AS "fullName"
FROM esn_user
WHERE username IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
</select>
</mapper>

View File

@ -1,48 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!-- UIWS 이식: 근무현황 피벗 집계. 원천 = tb_uiws_worklog_dtl + tb_uiws_worklog.
라벨: 근무자=esn_user.username(writer_id=username 조인, 없으면 writer_id),
근무처=company_id, 근무상태/유형=코드값(공통코드 미이식). -->
<mapper namespace="com.zioinfo.esn.uiws.stats.mapper.StatsMapper">
<select id="personalWork" resultType="map">
SELECT COALESCE(u.username, w.writer_id) AS worker,
w.work_status_cd AS work_status,
d.work_type_cd AS work_type,
d.company_id AS dyn,
COUNT(*) AS cnt
FROM tb_uiws_worklog_dtl d
JOIN tb_uiws_worklog w ON w.worklog_id = d.worklog_id
LEFT JOIN esn_user u ON u.username = w.writer_id
WHERE w.work_date BETWEEN #{from} AND #{to}
<if test="!scopeAll">
AND w.writer_id IN
<foreach collection="ownerIds" item="oid" open="(" separator="," close=")">#{oid}</foreach>
</if>
<if test="userId != null">AND w.writer_id = #{userId}</if>
GROUP BY COALESCE(u.username, w.writer_id), w.work_status_cd, d.work_type_cd, d.company_id
ORDER BY worker, work_status, work_type, dyn
</select>
<select id="companyWork" resultType="map">
SELECT d.company_id AS company,
d.work_type_cd AS work_type,
COALESCE(u.username, w.writer_id) AS dyn,
COUNT(*) AS cnt
FROM tb_uiws_worklog_dtl d
JOIN tb_uiws_worklog w ON w.worklog_id = d.worklog_id
LEFT JOIN esn_user u ON u.username = w.writer_id
WHERE w.work_date BETWEEN #{from} AND #{to}
<if test="!scopeAll">
AND w.writer_id IN
<foreach collection="ownerIds" item="oid" open="(" separator="," close=")">#{oid}</foreach>
</if>
<if test="companyId != null">AND d.company_id = #{companyId}</if>
<if test="userId != null">AND w.writer_id = #{userId}</if>
GROUP BY d.company_id, d.work_type_cd, COALESCE(u.username, w.writer_id)
ORDER BY company, work_type, dyn
</select>
</mapper>

View File

@ -1,181 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<!-- UIWS 이식: 업무일지 (tb_uiws_worklog / _dtl / _cmt). -->
<mapper namespace="com.zioinfo.esn.uiws.worklog.mapper.WorklogMapper">
<!-- ===================== worklog 헤더 ===================== -->
<insert id="insertWorklog" useGeneratedKeys="true" keyProperty="worklogId" keyColumn="worklog_id"
parameterType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklog">
INSERT INTO tb_uiws_worklog
(title, writer_id, work_date, work_status_cd, progress_cd, repeat_yn,
repeat_start_date, repeat_end_date, created_by, created_at)
VALUES
(#{title}, #{writerId}, #{workDate}, #{workStatusCd}, #{progressCd}, #{repeatYn},
#{repeatStartDate}, #{repeatEndDate}, #{createdBy}, #{createdAt})
</insert>
<update id="updateWorklog" parameterType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklog">
UPDATE tb_uiws_worklog SET
title = #{title}, writer_id = #{writerId}, work_date = #{workDate},
work_status_cd = #{workStatusCd}, progress_cd = #{progressCd}, repeat_yn = #{repeatYn},
repeat_start_date = #{repeatStartDate}, repeat_end_date = #{repeatEndDate},
updated_by = #{updatedBy}, updated_at = #{updatedAt}
WHERE worklog_id = #{worklogId}
</update>
<select id="findWorklogById" resultType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklog">
SELECT * FROM tb_uiws_worklog WHERE worklog_id = #{worklogId}
</select>
<select id="existsWorklogById" resultType="boolean">
SELECT EXISTS(SELECT 1 FROM tb_uiws_worklog WHERE worklog_id = #{worklogId})
</select>
<delete id="deleteWorklogById">
DELETE FROM tb_uiws_worklog WHERE worklog_id = #{worklogId}
</delete>
<sql id="scope">
<if test="!scopeAll">
AND writer_id IN
<foreach collection="ownerIds" item="oid" open="(" separator="," close=")">#{oid}</foreach>
</if>
</sql>
<select id="searchList" resultType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklog">
SELECT * FROM tb_uiws_worklog
WHERE work_date BETWEEN #{from} AND #{to}
<if test="writerId != null">AND writer_id = #{writerId}</if>
<if test="progressCd != null">AND progress_cd = #{progressCd}</if>
<include refid="scope"/>
ORDER BY work_date DESC, worklog_id DESC
LIMIT #{limit} OFFSET #{offset}
</select>
<select id="countList" resultType="long">
SELECT COUNT(*) FROM tb_uiws_worklog
WHERE work_date BETWEEN #{from} AND #{to}
<if test="writerId != null">AND writer_id = #{writerId}</if>
<if test="progressCd != null">AND progress_cd = #{progressCd}</if>
<include refid="scope"/>
</select>
<select id="findByMonth" resultType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklog">
SELECT * FROM tb_uiws_worklog
WHERE work_date BETWEEN #{from} AND #{to}
<if test="writerId != null">AND writer_id = #{writerId}</if>
<include refid="scope"/>
ORDER BY work_date ASC, worklog_id ASC
</select>
<select id="searchPopup" resultType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklog">
SELECT * FROM tb_uiws_worklog
WHERE 1=1
<if test="keyword != null">AND title LIKE '%' || #{keyword} || '%'</if>
<if test="writerId != null">AND writer_id = #{writerId}</if>
<include refid="scope"/>
ORDER BY work_date DESC, worklog_id DESC
LIMIT 50
</select>
<select id="progressSummary" resultType="map">
SELECT progress_cd AS "progressCd", COUNT(*) AS "cnt"
FROM tb_uiws_worklog
WHERE work_date BETWEEN #{from} AND #{to}
<include refid="scope"/>
GROUP BY progress_cd
</select>
<!-- ===================== 상세(dtl) ===================== -->
<insert id="insertDtl" useGeneratedKeys="true" keyProperty="dtlId" keyColumn="dtl_id"
parameterType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklogDtl">
INSERT INTO tb_uiws_worklog_dtl
(worklog_id, start_hour, end_hour, work_type_cd, company_id, work_content, issue_content,
sort_ord, created_by, created_at)
VALUES
(#{worklogId}, #{startHour}, #{endHour}, #{workTypeCd}, #{companyId}, #{workContent}, #{issueContent},
#{sortOrd}, #{createdBy}, #{createdAt})
</insert>
<update id="updateDtl" parameterType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklogDtl">
UPDATE tb_uiws_worklog_dtl SET
start_hour = #{startHour}, end_hour = #{endHour}, work_type_cd = #{workTypeCd},
company_id = #{companyId}, work_content = #{workContent}, issue_content = #{issueContent},
sort_ord = #{sortOrd}, updated_by = #{updatedBy}, updated_at = #{updatedAt}
WHERE dtl_id = #{dtlId}
</update>
<delete id="deleteDtlById">
DELETE FROM tb_uiws_worklog_dtl WHERE dtl_id = #{dtlId}
</delete>
<select id="findDtlByWorklogId" resultType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklogDtl">
SELECT * FROM tb_uiws_worklog_dtl
WHERE worklog_id = #{worklogId}
ORDER BY sort_ord ASC, start_hour ASC, dtl_id ASC
</select>
<!-- ===================== 댓글(cmt) ===================== -->
<insert id="insertCmt" useGeneratedKeys="true" keyProperty="cmtId" keyColumn="cmt_id"
parameterType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklogCmt">
INSERT INTO tb_uiws_worklog_cmt
(worklog_id, cmt_content, writer_id, kakao_sent_yn, confirm_yn, created_by, created_at)
VALUES
(#{worklogId}, #{cmtContent}, #{writerId}, #{kakaoSentYn}, #{confirmYn}, #{createdBy}, #{createdAt})
</insert>
<select id="findCmtById" resultType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklogCmt">
SELECT * FROM tb_uiws_worklog_cmt WHERE cmt_id = #{cmtId}
</select>
<update id="updateCmtKakaoSent">
UPDATE tb_uiws_worklog_cmt SET kakao_sent_yn = 'Y' WHERE cmt_id = #{cmtId}
</update>
<update id="confirmCmt">
UPDATE tb_uiws_worklog_cmt
SET confirm_yn = 'Y', updated_by = #{actor}, updated_at = now()
WHERE cmt_id = #{cmtId}
</update>
<select id="findCmtByWorklogId" resultType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklogCmt">
SELECT * FROM tb_uiws_worklog_cmt
WHERE worklog_id = #{worklogId}
ORDER BY created_at ASC, cmt_id ASC
</select>
<select id="countCommentsByWorklogIds" resultType="map">
SELECT worklog_id AS "worklogId", COUNT(*) AS "cnt"
FROM tb_uiws_worklog_cmt
WHERE worklog_id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
GROUP BY worklog_id
</select>
<select id="findUnconfirmedByWriter" resultType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklogCmt">
SELECT * FROM tb_uiws_worklog_cmt
WHERE writer_id = #{writerId} AND confirm_yn = 'N'
ORDER BY created_at DESC, cmt_id DESC
</select>
<!-- ===================== 사용자 라벨/이메일(코어 user 재사용) ===================== -->
<select id="findUserNames" resultType="map">
SELECT username AS "username", username AS "fullName"
FROM esn_user
WHERE username IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
</select>
<select id="findUserEmail" resultType="string">
SELECT email FROM esn_user WHERE username = #{username}
</select>
<select id="findWorklogsByIds" resultType="com.zioinfo.esn.uiws.worklog.model.UiwsWorklog">
SELECT * FROM tb_uiws_worklog
WHERE worklog_id IN
<foreach collection="ids" item="id" open="(" separator="," close=")">#{id}</foreach>
</select>
</mapper>

File diff suppressed because it is too large Load Diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

View File

@ -1,65 +0,0 @@
import api from './client'
/**
* UIWS API . ESN axios (baseURL='', esn_token JWT ) .
* ESN client baseURL /api/... .
* : { success, message, data }. res.data.data .
*/
// ── 2FA
export const verify2fa = (verifyToken: string, code: string) =>
api.post('/api/auth/verify', { verifyToken, code })
// ── 쪽지(message)
export const sendMessage = (body: object) => api.post('/api/messages', body)
export const listSent = (params: Record<string, unknown>) => api.get('/api/messages/sent', { params })
export const sentDetail = (id: number) => api.get(`/api/messages/sent/${id}`)
export const deleteSent = (ids: number[]) => api.delete('/api/messages/sent', { data: { ids } })
export const listReceived = (params: Record<string, unknown>) => api.get('/api/messages/received', { params })
export const receivedDetail = (id: number) => api.get(`/api/messages/received/${id}`)
export const deleteReceived = (ids: number[]) => api.delete('/api/messages/received', { data: { ids } })
export const unreadCount = () => api.get('/api/messages/unread-count')
// ── 일정(schedule)
export const scheduleCalendar = (params: Record<string, unknown>) => api.get('/api/schedules', { params })
export const scheduleAll = (params: Record<string, unknown>) => api.get('/api/schedules/all', { params })
export const scheduleSearch = (keyword: string) => api.get('/api/schedules/search', { params: { keyword } })
export const createSchedule = (body: object) => api.post('/api/schedules', body)
export const scheduleDetail = (id: number) => api.get(`/api/schedules/${id}`)
export const updateSchedule = (id: number, body: object) => api.put(`/api/schedules/${id}`, body)
export const deleteSchedule = (id: number) => api.delete(`/api/schedules/${id}`)
// ── 일지(diary)
export const diaryList = (params: Record<string, unknown>) => api.get('/api/diaries', { params })
export const createDiary = (body: object) => api.post('/api/diaries', body)
export const diaryDetail = (id: number) => api.get(`/api/diaries/${id}`)
export const updateDiary = (id: number, body: object) => api.put(`/api/diaries/${id}`, body)
export const deleteDiary = (id: number) => api.delete(`/api/diaries/${id}`)
// ── 첨부(attachment)
export const uploadAttachment = (refType: string, refId: number, file: File) => {
const fd = new FormData()
fd.append('refType', refType)
fd.append('refId', String(refId))
fd.append('file', file)
return api.post('/api/attachments', fd, { headers: { 'Content-Type': 'multipart/form-data' } })
}
export const deleteAttachment = (id: number) => api.delete(`/api/attachments/${id}`)
export const attachmentDownloadUrl = (id: number) => `/api/attachments/${id}/download`
// ── 업무일지(worklog)
export const worklogList = (params: Record<string, unknown>) => api.get('/api/worklogs', { params })
export const worklogCalendar = (params: Record<string, unknown>) => api.get('/api/worklogs/calendar', { params })
export const worklogSearch = (params: Record<string, unknown>) => api.get('/api/worklogs/search', { params })
export const worklogProgress = (params: Record<string, unknown>) => api.get('/api/worklogs/dashboard/progress', { params })
export const createWorklog = (body: object) => api.post('/api/worklogs', body)
export const worklogDetail = (id: number) => api.get(`/api/worklogs/${id}`)
export const updateWorklog = (id: number, body: object) => api.put(`/api/worklogs/${id}`, body)
export const deleteWorklog = (id: number) => api.delete(`/api/worklogs/${id}`)
export const addWorklogComment = (id: number, cmtContent: string) =>
api.post(`/api/worklogs/${id}/comments`, { cmtContent })
export const confirmWorklogComment = (cmtId: number) => api.post(`/api/worklogs/comments/${cmtId}/confirm`)
// ── 통계(stats)
export const personalWorkStats = (params: Record<string, unknown>) => api.get('/api/stats/personal-work', { params })
export const companyWorkStats = (params: Record<string, unknown>) => api.get('/api/stats/company-work', { params })

View File

@ -1,16 +0,0 @@
import { Moon, Sun } from 'lucide-react'
import { useTheme } from '../../theme/ThemeContext'
/** 다크/라이트 테마 토글 버튼. 사이드바 하단 등에 배치. */
export default function ThemeToggle() {
const { theme, toggle } = useTheme()
return (
<button onClick={toggle} title="테마 전환"
style={{ display: 'flex', alignItems: 'center', gap: 8, padding: '10px 12px',
background: 'none', border: '1px solid var(--uiws-border, #2d3448)', borderRadius: 8,
color: 'var(--uiws-text-muted, #8892b0)', cursor: 'pointer', fontSize: 13, width: '100%' }}>
{theme === 'dark' ? <Sun size={16} /> : <Moon size={16} />}
{theme === 'dark' ? '라이트 모드' : '다크 모드'}
</button>
)
}

View File

@ -1,171 +0,0 @@
/*
* UIWS ( var(--uiws-*) ).
* / / . ERP components/uiws/ .
*/
import { type ReactNode, type CSSProperties } from 'react'
const card: CSSProperties = {
background: 'var(--uiws-surface)',
border: '1px solid var(--uiws-border)',
borderRadius: 12,
boxShadow: 'var(--uiws-shadow)',
}
export function PageHeader({ title, subtitle, actions }: { title: string; subtitle?: string; actions?: ReactNode }) {
return (
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-end', marginBottom: 20 }}>
<div>
<h1 style={{ fontSize: 22, fontWeight: 700, color: 'var(--uiws-text)' }}>{title}</h1>
{subtitle && <div style={{ fontSize: 13, color: 'var(--uiws-text-muted)', marginTop: 4 }}>{subtitle}</div>}
</div>
<div style={{ display: 'flex', gap: 8 }}>{actions}</div>
</div>
)
}
export function Panel({ children, style }: { children: ReactNode; style?: CSSProperties }) {
return <div style={{ ...card, padding: 18, ...style }}>{children}</div>
}
export function Button({
children, onClick, variant = 'primary', type = 'button', disabled,
}: {
children: ReactNode; onClick?: () => void; variant?: 'primary' | 'ghost' | 'danger'
type?: 'button' | 'submit'; disabled?: boolean
}) {
const styles: Record<string, CSSProperties> = {
primary: { background: 'var(--uiws-primary)', color: 'var(--uiws-primary-contrast)', border: 'none' },
ghost: { background: 'transparent', color: 'var(--uiws-text-muted)', border: '1px solid var(--uiws-border)' },
danger: { background: 'var(--uiws-danger)', color: '#fff', border: 'none' },
}
return (
<button type={type} onClick={onClick} disabled={disabled}
style={{ padding: '9px 16px', borderRadius: 8, fontSize: 13, fontWeight: 600,
cursor: disabled ? 'not-allowed' : 'pointer', opacity: disabled ? 0.5 : 1, ...styles[variant] }}>
{children}
</button>
)
}
export function Input({
value, onChange, placeholder, type = 'text', style,
}: {
value: string; onChange: (v: string) => void; placeholder?: string; type?: string; style?: CSSProperties
}) {
return (
<input type={type} value={value} placeholder={placeholder} onChange={e => onChange(e.target.value)}
style={{ padding: '9px 12px', borderRadius: 8, fontSize: 13,
background: 'var(--uiws-input-bg)', border: '1px solid var(--uiws-border)', color: 'var(--uiws-text)', ...style }} />
)
}
export function FormField({ label, children }: { label: string; children: ReactNode }) {
return (
<label style={{ display: 'block', marginBottom: 12 }}>
<div style={{ fontSize: 12, color: 'var(--uiws-text-muted)', marginBottom: 6 }}>{label}</div>
{children}
</label>
)
}
export function SearchBar({ children }: { children: ReactNode }) {
return (
<Panel style={{ display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap', marginBottom: 16 }}>
{children}
</Panel>
)
}
export interface Column<T> {
key: string
header: string
render?: (row: T) => ReactNode
width?: number | string
align?: 'left' | 'center' | 'right'
}
export function DataGrid<T extends Record<string, unknown>>({
columns, rows, rowKey, onRowClick, empty = '데이터가 없습니다.',
}: {
columns: Column<T>[]; rows: T[]; rowKey: (row: T) => string | number
onRowClick?: (row: T) => void; empty?: string
}) {
return (
<div style={{ ...card, overflow: 'hidden' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13, color: 'var(--uiws-text)' }}>
<thead>
<tr style={{ background: 'var(--uiws-surface-2)' }}>
{columns.map(c => (
<th key={c.key} style={{ textAlign: c.align ?? 'left', padding: '11px 14px',
color: 'var(--uiws-text-muted)', fontWeight: 600, fontSize: 12,
borderBottom: '1px solid var(--uiws-border)', width: c.width }}>{c.header}</th>
))}
</tr>
</thead>
<tbody>
{rows.length === 0 ? (
<tr><td colSpan={columns.length} style={{ padding: 28, textAlign: 'center', color: 'var(--uiws-text-faint)' }}>{empty}</td></tr>
) : rows.map(row => (
<tr key={rowKey(row)} onClick={() => onRowClick?.(row)}
style={{ cursor: onRowClick ? 'pointer' : 'default', borderBottom: '1px solid var(--uiws-border)' }}
onMouseEnter={e => (e.currentTarget.style.background = 'var(--uiws-row-hover)')}
onMouseLeave={e => (e.currentTarget.style.background = 'transparent')}>
{columns.map(c => (
<td key={c.key} style={{ padding: '11px 14px', textAlign: c.align ?? 'left' }}>
{c.render ? c.render(row) : String(row[c.key] ?? '')}
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
export function Pagination({ page, totalPages, onChange }: { page: number; totalPages: number; onChange: (p: number) => void }) {
if (totalPages <= 1) return null
return (
<div style={{ display: 'flex', gap: 6, justifyContent: 'center', marginTop: 16 }}>
<Button variant="ghost" disabled={page <= 0} onClick={() => onChange(page - 1)}></Button>
<span style={{ alignSelf: 'center', fontSize: 13, color: 'var(--uiws-text-muted)' }}>
{page + 1} / {totalPages}
</span>
<Button variant="ghost" disabled={page >= totalPages - 1} onClick={() => onChange(page + 1)}></Button>
</div>
)
}
export function YnBadge({ yn, yes = '읽음', no = '안읽음' }: { yn: string; yes?: string; no?: string }) {
const on = yn === 'Y'
return (
<span style={{ fontSize: 11, fontWeight: 600, padding: '2px 9px', borderRadius: 999,
color: on ? 'var(--uiws-success)' : 'var(--uiws-text-muted)',
background: on ? 'rgba(46,204,113,0.12)' : 'var(--uiws-surface-2)',
border: '1px solid var(--uiws-border)' }}>
{on ? yes : no}
</span>
)
}
export function Modal({ title, onClose, children, footer }: { title: string; onClose: () => void; children: ReactNode; footer?: ReactNode }) {
return (
<div onClick={onClose}
style={{ position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.5)', display: 'flex',
justifyContent: 'center', alignItems: 'center', zIndex: 1000 }}>
<div onClick={e => e.stopPropagation()}
style={{ ...card, width: 'min(560px, 92vw)', maxHeight: '88vh', overflow: 'auto', padding: 22 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
<h2 style={{ fontSize: 17, fontWeight: 700, color: 'var(--uiws-text)' }}>{title}</h2>
<button onClick={onClose} style={{ background: 'none', border: 'none', color: 'var(--uiws-text-muted)', fontSize: 20, cursor: 'pointer' }}>×</button>
</div>
<div>{children}</div>
{footer && <div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end', marginTop: 18 }}>{footer}</div>}
</div>
</div>
)
}
export function Spinner({ label = '불러오는 중...' }: { label?: string }) {
return <div style={{ padding: 28, textAlign: 'center', color: 'var(--uiws-text-faint)', fontSize: 13 }}>{label}</div>
}

View File

@ -36,7 +36,7 @@ export default function AiAnalysis() {
value={alarmText} value={alarmText}
onChange={e => setAlarmText(e.target.value)} onChange={e => setAlarmText(e.target.value)}
rows={4} rows={4}
placeholder="알람 메시지를 입력하세요&#10;예: ESL-001 배터리 부족 경고 — Store: A매장 구미, Device: ESL-A123" placeholder="알람 메시지를 입력하세요&#10;예: ESL-001 배터리 부족 경고 — Store: LG이노텍 구미, Device: ESL-A123"
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-gray-300 placeholder-gray-600 resize-none mb-3" className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-gray-300 placeholder-gray-600 resize-none mb-3"
/> />
<button <button

View File

@ -1,62 +1,26 @@
import { useState } from 'react' import { useState } from 'react'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
import { login } from '../api/client' import { login } from '../api/client'
import { verify2fa } from '../api/uiws'
/**
* . UIWS 2FA :
* - 2FA off ({ twofa:"false", token }) ( 0).
* - 2FA on ({ twofa:"true", verifyToken, maskedEmail }) .
* ESN Tailwind (bg-card/text-brand/border-edge) .
*/
export default function Login() { export default function Login() {
const navigate = useNavigate() const navigate = useNavigate()
const [username, setUsername] = useState('') const [username, setUsername] = useState('')
const [password, setPassword] = useState('') const [password, setPassword] = useState('')
const [error, setError] = useState('') const [error, setError] = useState('')
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [step, setStep] = useState<'login' | 'verify'>('login')
const [verifyToken, setVerifyToken] = useState('')
const [maskedEmail, setMaskedEmail] = useState('')
const [code, setCode] = useState('')
function finishLogin(token: string) { async function handleSubmit(e: React.FormEvent) {
localStorage.setItem('esn_token', token)
localStorage.setItem('esn_user', username)
navigate('/dashboard')
}
async function handleLogin(e: React.FormEvent) {
e.preventDefault() e.preventDefault()
setLoading(true) setLoading(true)
setError('') setError('')
try { try {
const res = await login(username, password) const res = await login(username, password)
const data = res.data?.data const token = res.data?.data?.token
if (data?.twofa === 'true') { if (!token) throw new Error('토큰 없음')
setVerifyToken(data.verifyToken) localStorage.setItem('esn_token', token)
setMaskedEmail(data.maskedEmail || '') navigate('/dashboard')
setStep('verify')
} else {
if (!data?.token) throw new Error('토큰 없음')
finishLogin(data.token)
}
} catch { } catch {
setError('아이디/비밀번호가 올바르지 않거나 계정이 잠겼습니다.') setError('로그인 실패: 아이디/비밀번호를 확인하세요.')
} finally {
setLoading(false)
}
}
async function handleVerify(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
try {
const res = await verify2fa(verifyToken, code)
finishLogin(res.data?.data?.token)
} catch {
setError('인증 코드가 올바르지 않거나 만료되었습니다.')
} finally { } finally {
setLoading(false) setLoading(false)
} }
@ -66,14 +30,10 @@ export default function Login() {
<div className="min-h-screen bg-ink flex items-center justify-center"> <div className="min-h-screen bg-ink flex items-center justify-center">
<div className="w-full max-w-sm"> <div className="w-full max-w-sm">
<div className="text-center mb-8"> <div className="text-center mb-8">
<h1 className="text-2xl font-bold text-brand">GUARDiA ESN</h1> <h1 className="text-2xl font-bold text-brand">zioinfo-esn</h1>
<p className="text-sm text-gray-400 mt-1"> <p className="text-sm text-gray-400 mt-1">ESL </p>
{step === 'login' ? 'ESL 통합 관리 플랫폼' : '2차 인증'}
</p>
</div> </div>
<form onSubmit={handleSubmit} className="bg-card border border-edge rounded-lg p-6 space-y-4">
{step === 'login' ? (
<form onSubmit={handleLogin} className="bg-card border border-edge rounded-lg p-6 space-y-4">
<div> <div>
<label className="block text-xs text-gray-400 mb-1"></label> <label className="block text-xs text-gray-400 mb-1"></label>
<input <input
@ -102,39 +62,6 @@ export default function Login() {
{loading ? '로그인 중...' : '로그인'} {loading ? '로그인 중...' : '로그인'}
</button> </button>
</form> </form>
) : (
<form onSubmit={handleVerify} className="bg-card border border-edge rounded-lg p-6 space-y-4">
<p className="text-xs text-gray-400">
{maskedEmail ? `${maskedEmail} 로 발송된 인증코드를 입력하세요.` : '발송된 인증코드를 입력하세요.'}
</p>
<div>
<label className="block text-xs text-gray-400 mb-1"> 6</label>
<input
inputMode="numeric"
maxLength={6}
value={code}
onChange={e => setCode(e.target.value)}
placeholder="000000"
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white text-center tracking-[0.4em] placeholder-gray-600 focus:border-brand focus:outline-none"
/>
</div>
{error && <p className="text-red-400 text-xs">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full bg-brand hover:bg-brand2 text-white py-2 rounded text-sm font-medium transition-colors disabled:opacity-50"
>
{loading ? '인증 중...' : '인증하고 로그인'}
</button>
<button
type="button"
onClick={() => { setStep('login'); setCode(''); setError('') }}
className="w-full border border-edge text-gray-400 hover:text-white py-2 rounded text-sm transition-colors"
>
</button>
</form>
)}
</div> </div>
</div> </div>
) )

View File

@ -110,7 +110,7 @@ function StoreForm({ initial, onSave }: { initial: any; onSave: (d: any) => void
<form onSubmit={e => { e.preventDefault(); onSave(f) }} className="space-y-3"> <form onSubmit={e => { e.preventDefault(); onSave(f) }} className="space-y-3">
{[ {[
{ label: '테넌트', key: 'tenantCode', type: 'select', { label: '테넌트', key: 'tenantCode', type: 'select',
opts: ['TENANT_A','TENANT_B','EMART','ZIOINFO'] }, opts: ['LGINNOTEK','LGIT','EMART','ZIOINFO'] },
{ label: '매장코드', key: 'storeCode' }, { label: '매장코드', key: 'storeCode' },
{ label: '매장명', key: 'storeName' }, { label: '매장명', key: 'storeName' },
{ label: '주소', key: 'address' }, { label: '주소', key: 'address' },

View File

@ -1,118 +0,0 @@
import { useEffect, useState } from 'react'
import { listReceived, listSent, receivedDetail, sentDetail, sendMessage } from '../../api/uiws'
import { PageHeader, SearchBar, Input, Button, DataGrid, Pagination, Modal, FormField, YnBadge, Spinner, type Column } from '../../components/uiws/ui'
type Tab = 'received' | 'sent'
export default function MessageBox() {
const [tab, setTab] = useState<Tab>('received')
const [rows, setRows] = useState<any[]>([])
const [page, setPage] = useState(0)
const [totalPages, setTotalPages] = useState(0)
const [keyword, setKeyword] = useState('')
const [loading, setLoading] = useState(false)
const [compose, setCompose] = useState(false)
const [detail, setDetail] = useState<any>(null)
const load = async () => {
setLoading(true)
try {
const fn = tab === 'received' ? listReceived : listSent
const res = await fn({ page, size: 20, titleKeyword: keyword || undefined })
const data = res.data.data
setRows(data.content ?? [])
setTotalPages(data.totalPages ?? 0)
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [tab, page])
const receivedCols: Column<any>[] = [
{ key: 'title', header: '제목' },
{ key: 'senderNm', header: '보낸사람', width: 130 },
{ key: 'sentAt', header: '받은시각', width: 170 },
{ key: 'readYn', header: '상태', width: 90, render: r => <YnBadge yn={r.readYn} /> },
]
const sentCols: Column<any>[] = [
{ key: 'title', header: '제목' },
{ key: 'receiverSummary', header: '받는사람', width: 160 },
{ key: 'open', header: '개봉', width: 100, align: 'center', render: r => `${r.openCount}/${r.totalCount}` },
{ key: 'sentAt', header: '보낸시각', width: 170 },
]
const open = async (r: any) => {
const res = tab === 'received' ? await receivedDetail(r.messageId) : await sentDetail(r.messageId)
setDetail({ ...res.data.data, _tab: tab })
if (tab === 'received') load() // 개봉처리 반영
}
return (
<div className="uiws-scope">
<PageHeader title="쪽지" subtitle="UIWS 이식 · 사내 쪽지 송수신"
actions={<Button onClick={() => setCompose(true)}>+ </Button>} />
<SearchBar>
<Button variant={tab === 'received' ? 'primary' : 'ghost'} onClick={() => { setTab('received'); setPage(0) }}></Button>
<Button variant={tab === 'sent' ? 'primary' : 'ghost'} onClick={() => { setTab('sent'); setPage(0) }}></Button>
<div style={{ flex: 1 }} />
<Input value={keyword} onChange={setKeyword} placeholder="제목 검색" style={{ minWidth: 200 }} />
<Button variant="ghost" onClick={() => { setPage(0); load() }}></Button>
</SearchBar>
{loading ? <Spinner /> : (
<>
<DataGrid columns={tab === 'received' ? receivedCols : sentCols} rows={rows} rowKey={r => r.messageId} onRowClick={open} empty="쪽지가 없습니다." />
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
</>
)}
{compose && <Compose onClose={() => setCompose(false)} onSent={() => { setCompose(false); load() }} />}
{detail && <DetailModal detail={detail} onClose={() => setDetail(null)} />}
</div>
)
}
function Compose({ onClose, onSent }: { onClose: () => void; onSent: () => void }) {
const [receiverId, setReceiverId] = useState('')
const [title, setTitle] = useState('')
const [content, setContent] = useState('')
const [err, setErr] = useState('')
const send = async () => {
setErr('')
try {
await sendMessage({ title, content, receivers: [{ receiverId, rcvType: 'RECV' }] })
onSent()
} catch (e: any) { setErr(e?.response?.data?.message || '전송 실패') }
}
return (
<Modal title="쪽지 보내기" onClose={onClose}
footer={<><Button variant="ghost" onClick={onClose}></Button><Button onClick={send}></Button></>}>
<FormField label="받는사람 ID"><Input value={receiverId} onChange={setReceiverId} style={{ width: '100%' }} /></FormField>
<FormField label="제목"><Input value={title} onChange={setTitle} style={{ width: '100%' }} /></FormField>
<FormField label="내용"><Input value={content} onChange={setContent} style={{ width: '100%' }} /></FormField>
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>}
</Modal>
)
}
function DetailModal({ detail, onClose }: { detail: any; onClose: () => void }) {
return (
<Modal title={detail.title} onClose={onClose} footer={<Button variant="ghost" onClick={onClose}></Button>}>
<div style={{ fontSize: 13, color: 'var(--uiws-text-muted)', marginBottom: 10 }}>
{detail._tab === 'received' ? `보낸사람: ${detail.senderNm}` : `개봉 ${detail.openCount}/${detail.totalCount}`} · {detail.sentAt}
</div>
<div style={{ fontSize: 14, color: 'var(--uiws-text)', whiteSpace: 'pre-wrap' }}>{detail.content}</div>
{detail._tab === 'sent' && detail.receivers && (
<div style={{ marginTop: 14 }}>
<div style={{ fontSize: 12, color: 'var(--uiws-text-muted)', marginBottom: 6 }}> </div>
{detail.receivers.map((r: any, i: number) => (
<div key={i} style={{ display: 'flex', justifyContent: 'space-between', fontSize: 13, padding: '4px 0' }}>
<span>{r.receiverNm} ({r.rcvType})</span><YnBadge yn={r.readYn} />
</div>
))}
</div>
)}
</Modal>
)
}

View File

@ -1,121 +0,0 @@
import { useEffect, useState } from 'react'
import { scheduleCalendar, createSchedule, scheduleDetail, deleteSchedule } from '../../api/uiws'
import { PageHeader, Panel, Button, Modal, FormField, Input, Spinner } from '../../components/uiws/ui'
interface Sched { scheduleId: number; title: string; startDt: string; endDt: string; scheType: string; importanceCd?: string }
const WEEK = ['일', '월', '화', '수', '목', '금', '토']
export default function ScheduleCalendar() {
const [base, setBase] = useState(new Date())
const [type, setType] = useState<'PERSONAL' | 'DEPT'>('PERSONAL')
const [items, setItems] = useState<Sched[]>([])
const [loading, setLoading] = useState(false)
const [showForm, setShowForm] = useState<string | null>(null) // date string
const [detail, setDetail] = useState<any>(null)
const ym = `${base.getFullYear()}-${String(base.getMonth() + 1).padStart(2, '0')}`
const load = async () => {
setLoading(true)
try {
const res = await scheduleCalendar({ type, view: 'month', baseDate: `${ym}-01` })
setItems(res.data.data ?? [])
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [ym, type])
const first = new Date(base.getFullYear(), base.getMonth(), 1)
const startPad = first.getDay()
const daysInMonth = new Date(base.getFullYear(), base.getMonth() + 1, 0).getDate()
const cells: (number | null)[] = [...Array(startPad).fill(null), ...Array.from({ length: daysInMonth }, (_, i) => i + 1)]
const byDay = (d: number) => {
const ds = `${ym}-${String(d).padStart(2, '0')}`
return items.filter(s => (s.startDt ?? '').slice(0, 10) <= ds && ds <= (s.endDt ?? '').slice(0, 10))
}
const move = (delta: number) => setBase(new Date(base.getFullYear(), base.getMonth() + delta, 1))
return (
<div className="uiws-scope">
<PageHeader title="일정" subtitle="UIWS 이식 · 개인/부서 일정 달력"
actions={<Button onClick={() => setShowForm(`${ym}-01`)}>+ </Button>} />
<Panel style={{ display: 'flex', gap: 10, alignItems: 'center', marginBottom: 16 }}>
<Button variant="ghost" onClick={() => move(-1)}> </Button>
<div style={{ fontWeight: 700, minWidth: 130, textAlign: 'center', color: 'var(--uiws-text)' }}>{ym}</div>
<Button variant="ghost" onClick={() => move(1)}> </Button>
<div style={{ flex: 1 }} />
<Button variant={type === 'PERSONAL' ? 'primary' : 'ghost'} onClick={() => setType('PERSONAL')}></Button>
<Button variant={type === 'DEPT' ? 'primary' : 'ghost'} onClick={() => setType('DEPT')}></Button>
</Panel>
{loading ? <Spinner /> : (
<div style={{ background: 'var(--uiws-surface)', border: '1px solid var(--uiws-border)', borderRadius: 12, overflow: 'hidden' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(7,1fr)' }}>
{WEEK.map((w, i) => (
<div key={w} style={{ padding: '10px 0', textAlign: 'center', fontSize: 12, fontWeight: 600,
color: i === 0 ? 'var(--uiws-danger)' : 'var(--uiws-text-muted)', borderBottom: '1px solid var(--uiws-border)' }}>{w}</div>
))}
{cells.map((d, i) => (
<div key={i} onClick={() => d && setShowForm(`${ym}-${String(d).padStart(2, '0')}`)}
style={{ minHeight: 96, padding: 6, borderRight: '1px solid var(--uiws-border)', borderBottom: '1px solid var(--uiws-border)',
cursor: d ? 'pointer' : 'default' }}>
{d && <div style={{ fontSize: 12, color: 'var(--uiws-text-muted)', marginBottom: 4 }}>{d}</div>}
{d && byDay(d).slice(0, 3).map(s => (
<div key={s.scheduleId} onClick={e => { e.stopPropagation(); scheduleDetail(s.scheduleId).then(r => setDetail(r.data.data)) }}
style={{ fontSize: 11, padding: '2px 6px', borderRadius: 6, marginBottom: 3,
background: 'var(--uiws-primary-soft)', color: 'var(--uiws-primary)', overflow: 'hidden', whiteSpace: 'nowrap', textOverflow: 'ellipsis' }}>
{s.title}
</div>
))}
</div>
))}
</div>
</div>
)}
{showForm && <ScheduleForm date={showForm} type={type} onClose={() => setShowForm(null)} onSaved={() => { setShowForm(null); load() }} />}
{detail && <ScheduleDetailModal detail={detail} onClose={() => setDetail(null)} onDeleted={() => { setDetail(null); load() }} />}
</div>
)
}
function ScheduleForm({ date, type, onClose, onSaved }: { date: string; type: string; onClose: () => void; onSaved: () => void }) {
const [title, setTitle] = useState('')
const [startDt, setStartDt] = useState(`${date}T09:00:00`)
const [endDt, setEndDt] = useState(`${date}T18:00:00`)
const [content, setContent] = useState('')
const [err, setErr] = useState('')
const save = async () => {
setErr('')
try {
await createSchedule({ scheType: type, title, startDt, endDt, content, attachmentIds: [] })
onSaved()
} catch (e: any) { setErr(e?.response?.data?.message || '저장 실패') }
}
return (
<Modal title="일정 등록" onClose={onClose}
footer={<><Button variant="ghost" onClick={onClose}></Button><Button onClick={save}></Button></>}>
<FormField label="제목"><Input value={title} onChange={setTitle} style={{ width: '100%' }} /></FormField>
<FormField label="시작(yyyy-MM-ddTHH:mm:ss)"><Input value={startDt} onChange={setStartDt} style={{ width: '100%' }} /></FormField>
<FormField label="종료"><Input value={endDt} onChange={setEndDt} style={{ width: '100%' }} /></FormField>
<FormField label="내용"><Input value={content} onChange={setContent} style={{ width: '100%' }} /></FormField>
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>}
</Modal>
)
}
function ScheduleDetailModal({ detail, onClose, onDeleted }: { detail: any; onClose: () => void; onDeleted: () => void }) {
const remove = async () => { await deleteSchedule(detail.scheduleId); onDeleted() }
return (
<Modal title={detail.title} onClose={onClose}
footer={<><Button variant="danger" onClick={remove}></Button><Button variant="ghost" onClick={onClose}></Button></>}>
<div style={{ fontSize: 13, color: 'var(--uiws-text-muted)', marginBottom: 8 }}>{detail.scheType} · {detail.startDt} ~ {detail.endDt}</div>
<div style={{ fontSize: 14, color: 'var(--uiws-text)' }}>{detail.content || '내용 없음'}</div>
</Modal>
)
}

View File

@ -1,74 +0,0 @@
import { useEffect, useState } from 'react'
import { personalWorkStats, companyWorkStats } from '../../api/uiws'
import { PageHeader, SearchBar, Input, Button, Spinner } from '../../components/uiws/ui'
interface PivotColumn { key: string; label: string }
interface PivotResponse { fixedColumns: PivotColumn[]; dynamicColumns: PivotColumn[]; rows: Record<string, unknown>[] }
type Mode = 'personal' | 'company'
export default function StatsPivot() {
const [mode, setMode] = useState<Mode>('personal')
const [data, setData] = useState<PivotResponse | null>(null)
const [loading, setLoading] = useState(false)
const today = new Date().toISOString().slice(0, 10)
const weekAgo = new Date(Date.now() - 6 * 864e5).toISOString().slice(0, 10)
const [fromDate, setFromDate] = useState(weekAgo)
const [toDate, setToDate] = useState(today)
const load = async () => {
setLoading(true)
try {
const fn = mode === 'personal' ? personalWorkStats : companyWorkStats
const res = await fn({ fromDate, toDate })
setData(res.data.data)
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [mode])
const cols = data ? [...data.fixedColumns, ...data.dynamicColumns] : []
return (
<div className="uiws-scope">
<PageHeader title="업무통계" subtitle="UIWS 이식 · 근무현황 동적 피벗" />
<SearchBar>
<Button variant={mode === 'personal' ? 'primary' : 'ghost'} onClick={() => setMode('personal')}> </Button>
<Button variant={mode === 'company' ? 'primary' : 'ghost'} onClick={() => setMode('company')}> </Button>
<div style={{ flex: 1 }} />
<Input type="date" value={fromDate} onChange={setFromDate} />
<span style={{ color: 'var(--uiws-text-muted)' }}>~</span>
<Input type="date" value={toDate} onChange={setToDate} />
<Button variant="ghost" onClick={load}></Button>
</SearchBar>
{loading ? <Spinner /> : !data || data.rows.length === 0 ? (
<div style={{ padding: 28, textAlign: 'center', color: 'var(--uiws-text-faint)' }}> .</div>
) : (
<div style={{ background: 'var(--uiws-surface)', border: '1px solid var(--uiws-border)', borderRadius: 12, overflow: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13, color: 'var(--uiws-text)' }}>
<thead>
<tr style={{ background: 'var(--uiws-surface-2)' }}>
{cols.map(c => (
<th key={c.key} style={{ padding: '11px 14px', textAlign: 'left', fontSize: 12, fontWeight: 600,
color: 'var(--uiws-text-muted)', borderBottom: '1px solid var(--uiws-border)', whiteSpace: 'nowrap' }}>{c.label}</th>
))}
</tr>
</thead>
<tbody>
{data.rows.map((row, i) => (
<tr key={i} style={{ borderBottom: '1px solid var(--uiws-border)' }}>
{cols.map(c => (
<td key={c.key} style={{ padding: '10px 14px' }}>{String(row[c.key] ?? '')}</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}

View File

@ -1,153 +0,0 @@
import { useEffect, useState } from 'react'
import { worklogList, worklogProgress, createWorklog, worklogDetail, deleteWorklog, addWorklogComment } from '../../api/uiws'
import { PageHeader, SearchBar, Input, Button, DataGrid, Pagination, Modal, FormField, Spinner, type Column } from '../../components/uiws/ui'
interface WorklogRow extends Record<string, unknown> {
worklogId: number; title: string; writerNm: string; workDate: string; progressCd: string; commentCount: number
}
const PROGRESS_LABEL: Record<string, string> = { ONGOING: '진행중', DONE: '종료' }
export default function WorklogList() {
const [rows, setRows] = useState<WorklogRow[]>([])
const [page, setPage] = useState(0)
const [totalPages, setTotalPages] = useState(0)
const [progress, setProgress] = useState<{ progressCd: string; progressNm: string; count: number }[]>([])
const [filterProgress, setFilterProgress] = useState('')
const [keyword, setKeyword] = useState('')
const [loading, setLoading] = useState(false)
const [showForm, setShowForm] = useState(false)
const [detail, setDetail] = useState<any>(null)
const load = async () => {
setLoading(true)
try {
const res = await worklogList({ page, size: 20, progressCd: filterProgress || undefined })
const data = res.data.data
setRows(data.content ?? [])
setTotalPages(data.totalPages ?? 0)
const pr = await worklogProgress({})
setProgress(pr.data.data ?? [])
} finally {
setLoading(false)
}
}
useEffect(() => { load() }, [page, filterProgress])
const columns: Column<WorklogRow>[] = [
{ key: 'workDate', header: '근무일자', width: 120 },
{ key: 'title', header: '제목' },
{ key: 'writerNm', header: '작성자', width: 120 },
{ key: 'progressCd', header: '진행', width: 90, render: r => PROGRESS_LABEL[r.progressCd] ?? r.progressCd },
{ key: 'commentCount', header: '댓글', width: 70, align: 'center' },
]
const openDetail = async (r: WorklogRow) => {
const res = await worklogDetail(r.worklogId)
setDetail(res.data.data)
}
return (
<div className="uiws-scope">
<PageHeader title="업무일지" subtitle="UIWS 이식 · 업무일지 작성/조회/진행관리"
actions={<Button onClick={() => setShowForm(true)}>+ </Button>} />
<div style={{ display: 'flex', gap: 10, marginBottom: 16, flexWrap: 'wrap' }}>
{progress.map(p => (
<div key={p.progressCd} onClick={() => { setFilterProgress(filterProgress === p.progressCd ? '' : p.progressCd); setPage(0) }}
style={{ cursor: 'pointer', padding: '12px 18px', borderRadius: 10, minWidth: 130,
background: 'var(--uiws-surface)', border: `1px solid ${filterProgress === p.progressCd ? 'var(--uiws-primary)' : 'var(--uiws-border)'}` }}>
<div style={{ fontSize: 12, color: 'var(--uiws-text-muted)' }}>{p.progressNm}</div>
<div style={{ fontSize: 24, fontWeight: 700, color: 'var(--uiws-primary)' }}>{p.count}</div>
</div>
))}
</div>
<SearchBar>
<Input value={keyword} onChange={setKeyword} placeholder="제목 검색" style={{ minWidth: 220 }} />
<Button variant="ghost" onClick={() => { setPage(0); load() }}></Button>
{filterProgress && <Button variant="ghost" onClick={() => setFilterProgress('')}> : {PROGRESS_LABEL[filterProgress] ?? filterProgress}</Button>}
</SearchBar>
{loading ? <Spinner /> : (
<>
<DataGrid columns={columns} rows={rows} rowKey={r => r.worklogId} onRowClick={openDetail} empty="업무일지가 없습니다." />
<Pagination page={page} totalPages={totalPages} onChange={setPage} />
</>
)}
{showForm && <WorklogForm onClose={() => setShowForm(false)} onSaved={() => { setShowForm(false); load() }} />}
{detail && <WorklogDetailModal detail={detail} onClose={() => setDetail(null)} onChanged={() => { setDetail(null); load() }} />}
</div>
)
}
function WorklogForm({ onClose, onSaved }: { onClose: () => void; onSaved: () => void }) {
const [title, setTitle] = useState('')
const [writerId, setWriterId] = useState(localStorage.getItem('esn_user') || 'admin')
const [workDate, setWorkDate] = useState(new Date().toISOString().slice(0, 10))
const [workStatusCd, setWorkStatusCd] = useState('NORMAL')
const [err, setErr] = useState('')
const save = async () => {
setErr('')
try {
await createWorklog({ title, writerId, workDate, workStatusCd, progressCd: 'ONGOING', details: [] })
onSaved()
} catch (e: any) {
setErr(e?.response?.data?.message || '저장 실패')
}
}
return (
<Modal title="업무일지 작성" onClose={onClose}
footer={<><Button variant="ghost" onClick={onClose}></Button><Button onClick={save}></Button></>}>
<FormField label="제목"><Input value={title} onChange={setTitle} style={{ width: '100%' }} /></FormField>
<FormField label="작성자 ID"><Input value={writerId} onChange={setWriterId} style={{ width: '100%' }} /></FormField>
<FormField label="근무일자"><Input type="date" value={workDate} onChange={setWorkDate} style={{ width: '100%' }} /></FormField>
<FormField label="근무상태 코드"><Input value={workStatusCd} onChange={setWorkStatusCd} style={{ width: '100%' }} /></FormField>
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 13 }}>{err}</div>}
</Modal>
)
}
function WorklogDetailModal({ detail, onClose, onChanged }: { detail: any; onClose: () => void; onChanged: () => void }) {
const [comment, setComment] = useState('')
const [err, setErr] = useState('')
const remove = async () => {
await deleteWorklog(detail.worklogId)
onChanged()
}
const addCmt = async () => {
setErr('')
try {
await addWorklogComment(detail.worklogId, comment)
setComment('')
onChanged()
} catch (e: any) {
setErr(e?.response?.data?.message || '댓글 등록 실패')
}
}
return (
<Modal title={detail.title} onClose={onClose}
footer={<><Button variant="danger" onClick={remove}></Button><Button variant="ghost" onClick={onClose}></Button></>}>
<div style={{ fontSize: 13, color: 'var(--uiws-text-muted)', marginBottom: 12 }}>
{detail.writerNm} · {detail.workDate} · {detail.progressCd}
</div>
<div style={{ fontSize: 12, color: 'var(--uiws-text-muted)', marginBottom: 6 }}> {detail.details?.length ?? 0}</div>
<div style={{ marginTop: 14 }}>
<div style={{ fontSize: 12, color: 'var(--uiws-text-muted)', marginBottom: 8 }}></div>
{(detail.comments ?? []).map((c: any) => (
<div key={c.cmtId} style={{ padding: '8px 10px', borderRadius: 8, background: 'var(--uiws-surface-2)', marginBottom: 6, fontSize: 13 }}>
<b>{c.writerNm}</b> · <span style={{ color: 'var(--uiws-text-muted)' }}>{c.createdAt}</span><br />{c.cmtContent}
</div>
))}
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<Input value={comment} onChange={setComment} placeholder="댓글 입력(관리자/매니저)" style={{ flex: 1 }} />
<Button onClick={addCmt}></Button>
</div>
{err && <div style={{ color: 'var(--uiws-danger)', fontSize: 12, marginTop: 6 }}>{err}</div>}
</div>
</Modal>
)
}

View File

@ -1,38 +0,0 @@
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
export type ThemeMode = 'dark' | 'light'
interface ThemeCtx {
theme: ThemeMode
toggle: () => void
setTheme: (t: ThemeMode) => void
}
const Ctx = createContext<ThemeCtx>({ theme: 'dark', toggle: () => {}, setTheme: () => {} })
const STORAGE_KEY = 'esn_theme'
function applyTheme(t: ThemeMode) {
document.documentElement.setAttribute('data-theme', t)
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<ThemeMode>(() => {
const saved = localStorage.getItem(STORAGE_KEY)
return saved === 'light' ? 'light' : 'dark'
})
useEffect(() => {
applyTheme(theme)
localStorage.setItem(STORAGE_KEY, theme)
}, [theme])
const setTheme = (t: ThemeMode) => setThemeState(t)
const toggle = () => setThemeState(prev => (prev === 'dark' ? 'light' : 'dark'))
return <Ctx.Provider value={{ theme, toggle, setTheme }}>{children}</Ctx.Provider>
}
export function useTheme() {
return useContext(Ctx)
}

View File

@ -1,52 +0,0 @@
/*
* GUARDiA ESN 테마 토큰 (UIWS 이식 화면 공통).
* 다크/라이트 모드 지원. UIWS 화면/컴포넌트는 색상 하드코딩 금지 아래 CSS 변수만 사용한다.
* 기존 ESN 화면(Tailwind 토큰 다크) 영향 없음( 변수는 UIWS 스코프에서만 참조).
*
* 다크 기본값은 기존 ESN 팔레트(ink #0b0f17 / panel #131927 / card #1a2234 / brand #00a0c8) 정합.
*/
:root,
:root[data-theme='dark'] {
--uiws-bg: #0b0f17;
--uiws-surface: #1a2234;
--uiws-surface-2: #131927;
--uiws-border: #26304a;
--uiws-text: #e6edf3;
--uiws-text-muted: #8892b0;
--uiws-text-faint: #4d5568;
--uiws-primary: #00a0c8;
--uiws-primary-contrast: #ffffff;
--uiws-primary-soft: rgba(0, 160, 200, 0.14);
--uiws-danger: #e74c3c;
--uiws-success: #3ddc97;
--uiws-warning: #f1c40f;
--uiws-row-hover: rgba(255, 255, 255, 0.04);
--uiws-input-bg: #0b0f17;
--uiws-shadow: 0 4px 20px rgba(0, 0, 0, 0.35);
}
:root[data-theme='light'] {
--uiws-bg: #f4f6fb;
--uiws-surface: #ffffff;
--uiws-surface-2: #eef1f7;
--uiws-border: #d8deea;
--uiws-text: #14202e;
--uiws-text-muted: #5b6478;
--uiws-text-faint: #97a0b5;
--uiws-primary: #0089ab;
--uiws-primary-contrast: #ffffff;
--uiws-primary-soft: rgba(0, 137, 171, 0.10);
--uiws-danger: #d63b2b;
--uiws-success: #1f9e58;
--uiws-warning: #c79a08;
--uiws-row-hover: rgba(0, 0, 0, 0.035);
--uiws-input-bg: #ffffff;
--uiws-shadow: 0 4px 18px rgba(20, 30, 60, 0.10);
}
/* UIWS 화면 컨테이너 — 토큰 기반 기본 타이포/배경 */
.uiws-scope {
color: var(--uiws-text);
}
.uiws-scope a { color: var(--uiws-primary); }