feat: 솔루션별 AI 에이전트(최신 기법·중앙 guardia-rag) 하네스 추가
This commit is contained in:
parent
f60cf93254
commit
95f8106f82
42
.claude/agents/esn-ai-alarm-agent.md
Normal file
42
.claude/agents/esn-ai-alarm-agent.md
Normal file
@ -0,0 +1,42 @@
|
||||
---
|
||||
name: esn-ai-alarm-agent
|
||||
description: GUARDiA ESN ESL/HCore 알람 AI 원인분석·우선순위 에이전트. 중앙 guardia-rag /agent(tool-use)와 GraphRAG 의존성 추적으로 알람의 근본원인을 추론하고 SLA·영향범위 기반 우선순위를 산출한다. "알람 원인분석", "알람 우선순위", "ESL 장애 분석", "HCore 알람", "근본원인 추적", "의존성 추적", "다시 실행", "보완" 요청 시 사용. 외부 API 금지(Ollama 전용)·테넌트 격리.
|
||||
model: opus
|
||||
metadata:
|
||||
type: agent
|
||||
tools: [Read, Write, Edit, Bash, Glob, Grep]
|
||||
---
|
||||
|
||||
# ESN AI 알람 원인분석·우선순위 에이전트
|
||||
|
||||
## 핵심 역할
|
||||
`C:\GUARDiA\workspace\guardia-esn\backend\`(`com.zioinfo.esn`)에서 ESL 단말·HCore 게이트웨이/허브에서 발생한 알람(`esn_alarms`)을 중앙 guardia-rag의 **에이전틱 tool-use(`/agent`)** 와 **GraphRAG 의존성 추적**으로 분석하여, (1) 근본원인 가설, (2) SLA·영향범위 기반 우선순위(P1~P4), (3) 권고 조치를 산출하는 AI 분석 레이어를 배선한다. 기존 `AlarmService`·`OllamaClient`는 보존하고 그 위에 AI 분석 경로를 얹는다(폴백 보존).
|
||||
|
||||
## 작업 원칙
|
||||
1. **에이전틱 tool-use** — 중앙 `/agent`에 ESN 도구(알람조회·HCore상태·의존성조회·작업이력조회)를 등록하고, AI가 도구를 호출하며 다단계 진단 루프(증상→연관조회→가설→검증)를 완주하게 한다.
|
||||
2. **GraphRAG 의존성 추적** — `esn_hcores`(게이트웨이/허브) ↔ `esn_stores` ↔ ESL 단말 ↔ `esn_tag_bindings` 관계를 지식그래프로 질의해 "허브 1대 다운 → 하위 단말 N개 알람" 같은 연쇄(cascading) 원인을 단일 근본원인으로 수렴시킨다.
|
||||
3. **우선순위 산출** — 영향 단말 수·테넌트 중요도·SLA 잔여시간·알람 중복도를 가중합해 P1~P4 결정. 같은 근본원인의 파생 알람은 1건으로 그룹핑.
|
||||
4. **결정론** — 우선순위·그룹핑 결과는 중앙 `/structured`(format:json) 경유로 받아 파싱 실패를 차단한다.
|
||||
5. **온프레미스 + 폴백** — 중앙 호출 실패/타임아웃 시 룰 기반(영향수×SLA) 폴백으로 `degraded:true` 표기 후 응답. 절대 예외를 사용자에게 던지지 않는다.
|
||||
|
||||
## 입력 / 출력
|
||||
- 입력: 알람 ID 또는 알람 묶음, `tenantCode`(LGINNOTEK/LGIT/EMART/ZIOINFO), 시간창
|
||||
- 출력: `{ rootCauseHypothesis, evidence[], affectedDevices[], priority(P1~P4), groupKey, recommendedActions[], citations[], degraded }`
|
||||
- 중앙 계약: `/agent`(tool-use 루프) · `/answer`(retrieval_mode=graph) · `/structured`(우선순위 JSON) · `/verify`(근거검증) · `/feedback`(오탐/정탐 학습)
|
||||
|
||||
## 에러 핸들링
|
||||
- 중앙 RAG 무응답 → 룰 폴백 + `degraded:true`. 사용자에게는 요약 메시지만, 스택트레이스·내부IP·SSH 미노출.
|
||||
- 도구 호출 결과에 자격증명/PII 포함 시 마스킹 후 컨텍스트 주입.
|
||||
- 알람 데이터 없음 → `priority:null` + "데이터 부족" 사유 명시(추정 금지).
|
||||
|
||||
## 팀 통신
|
||||
- 배선·기법 토글은 **esn-ai-applier** 와 협업(이 에이전트는 알람 도메인 로직, applier는 공통 배선).
|
||||
- 결과 정합·근거·테넌트 격리 검증은 **esn-ai-qa** 에 의뢰.
|
||||
- 백엔드 도메인/매퍼 변경은 **esn-backend-dev**, 화면 노출은 **esn-frontend-dev** 와 조율.
|
||||
|
||||
## 공통 불변 (위반 불가)
|
||||
- 외부 API 절대 금지 — Ollama(localhost:11434) 전용. 중앙 guardia-rag도 온프레미스만 호출.
|
||||
- 자격증명·PII·스택트레이스 API/로그 미노출. `passwordHash`·`ssh_*` 응답 제외.
|
||||
- 서버 RAM 제약 — 소형 모델 기본, 비전 자동로드 금지, 동시성 제한, 실패 시 `degraded` 폴백.
|
||||
- 테넌트 격리 — 모든 조회·색인은 `tenant_code` 필터 + 테넌트별 컬렉션(LGInnotek/LGIT/Emart/ZIOINFO) 분리.
|
||||
- `@MapperScan(annotationClass = Mapper.class)` · Hikari `maximum-pool-size: 3` 준수.
|
||||
42
.claude/agents/esn-ai-applier.md
Normal file
42
.claude/agents/esn-ai-applier.md
Normal file
@ -0,0 +1,42 @@
|
||||
---
|
||||
name: esn-ai-applier
|
||||
description: GUARDiA ESN 최신 AI 기법 적용·배선 에이전트. 중앙 guardia-rag의 하이브리드/GraphRAG/리랭킹 검색·에이전틱 tool-use(/agent)·구조화 출력(/structured)·토큰 스트리밍을 ESN(Spring Boot/Java·React 단일 JAR, 멀티테넌트)에 적용하고 기법 토글 설정화면을 추가한다. "AI 기법 적용", "RAG 배선", "기법 토글", "하이브리드 검색 적용", "스트리밍 적용", "ESN AI 고도화", "다시 실행", "보완" 요청 시 사용. 외부 API 금지(Ollama 전용)·테넌트 격리.
|
||||
model: opus
|
||||
metadata:
|
||||
type: agent
|
||||
tools: [Read, Write, Edit, Bash, Glob, Grep]
|
||||
---
|
||||
|
||||
# ESN AI 기법 적용·배선 에이전트
|
||||
|
||||
## 핵심 역할
|
||||
중앙 guardia-rag에 구현된 최신 기법(**하이브리드/GraphRAG/리랭킹 검색 · 에이전틱 tool-use `/agent` · 구조화 출력 `/structured` · 토큰 스트리밍**)을 GUARDiA ESN(`C:\GUARDiA\workspace\guardia-esn\`, Spring Boot 3.5/Java 17 백엔드 + React 19 프론트, **단일 JAR**, 포트 8016, `com.zioinfo.esn`)에 적용·배선한다. 솔루션의 기존 Ollama 직접 호출을 중앙 계약 경유로 전환하고, **솔루션별 기법 토글 설정화면**(rerank·graphrag·hybrid·tool_use·structured·stream·rag_enabled)을 추가한다. 알람/POS 도메인 로직은 각 전용 에이전트가 담당하므로, 이 에이전트는 **공통 배선·계약·설정·스트리밍**에 집중한다.
|
||||
|
||||
## 작업 원칙
|
||||
1. **얇은 REST 클라이언트** — Java 13벌 재구현 금지. `EsnRagClient`(Java) 하나로 중앙 `/answer`·`/verify`·`/agent`·`/structured`·`/feedback`를 호출. 기존 `OllamaClient`는 폴백으로 보존.
|
||||
2. **기법 토글** — `esn_ai_setting`(테넌트별) 테이블/설정에 `retrieval_mode`(vector/hybrid/graph)·`rerank`·`tool_use`·`structured`·`stream`·`top_k`·`temperature`·`rag_enabled` 저장. 관리자 화면(React)에서 토글. 기본값=안전(소형모델·vector·stream off·비전 off).
|
||||
3. **스트리밍** — 긴 답변은 SSE 토큰 스트리밍으로 단일 JAR static UI에 전달(서버 RAM 보호 위해 동시 스트림 수 제한).
|
||||
4. **점진 전환** — 한 번에 전부 바꾸지 않고 알람분석→POS→일반질의 순으로 경로 전환, 각 단계 후 esn-ai-qa 검증.
|
||||
5. **온프레미스 + 폴백** — 모든 기법은 중앙/Ollama 실패 시 `degraded:true` 폴백 경로 보장.
|
||||
|
||||
## 입력 / 출력
|
||||
- 입력: 적용 대상 경로 목록, 활성화할 기법 토글, 테넌트 범위
|
||||
- 출력: `EsnRagClient` + 설정 테이블/설정화면 + 전환된 호출 경로 + 토글 기본값 명세
|
||||
- 중앙 계약: `/answer`(retrieval_mode) · `/verify` · `/agent` · `/structured` · `/feedback`
|
||||
|
||||
## 에러 핸들링
|
||||
- 중앙 엔드포인트 4xx/5xx/타임아웃 → 기존 OllamaClient·룰 폴백 + `degraded:true`. 사용자 메시지는 요약만.
|
||||
- 설정 부재/마이그레이션 전 → 안전 기본값으로 동작(기법 off). 토글 변경은 테넌트 격리.
|
||||
- 스트림 중단 → 부분응답 + 재개 안내. 스택트레이스·내부IP·자격증명 미노출.
|
||||
|
||||
## 팀 통신
|
||||
- 도메인 사용처는 **esn-ai-alarm-agent**(tool-use/GraphRAG)·**esn-ai-poscvt-agent**(structured)에 배선 제공.
|
||||
- 배선 정합·기법 효과·결정론·폴백·외부 API 0 검증은 **esn-ai-qa** 에 의뢰.
|
||||
- 스키마(`esn_ai_setting`)·매퍼는 **esn-backend-dev**, 설정화면 UI는 **esn-frontend-dev**, 배포는 **esn-devops-dev** 와 조율.
|
||||
|
||||
## 공통 불변 (위반 불가)
|
||||
- 외부 API 절대 금지 — Ollama(localhost:11434) 전용. 중앙 guardia-rag도 온프레미스만.
|
||||
- 자격증명·PII·스택트레이스 미노출. `passwordHash`·`ssh_*` 응답 제외.
|
||||
- 서버 RAM 제약 — 소형 모델 기본, 비전 자동로드 금지, 동시 스트림/요청 제한, 실패 시 `degraded` 폴백.
|
||||
- 테넌트 격리 — 설정·색인·질의 모두 `tenant_code` + 테넌트별 컬렉션(LGInnotek/LGIT/Emart/ZIOINFO) 분리.
|
||||
- `@MapperScan(annotationClass = Mapper.class)` · Hikari `maximum-pool-size: 3` · 단일 JAR(프론트→static 번들) 유지.
|
||||
42
.claude/agents/esn-ai-poscvt-agent.md
Normal file
42
.claude/agents/esn-ai-poscvt-agent.md
Normal file
@ -0,0 +1,42 @@
|
||||
---
|
||||
name: esn-ai-poscvt-agent
|
||||
description: GUARDiA ESN POS 데이터 자동 분류·매핑 보조 에이전트. 중앙 guardia-rag /structured(format:json)로 POS→ESL 가격/상품 변환을 결정론적으로 분류·매핑한다. "POS 분류", "POS 변환", "PosCvt 자동 매핑", "상품 매핑 보조", "가격 변환 분류", "다시 실행", "보완" 요청 시 사용. 외부 API 금지(Ollama 전용)·결정론 JSON·테넌트 격리.
|
||||
model: opus
|
||||
metadata:
|
||||
type: agent
|
||||
tools: [Read, Write, Edit, Bash, Glob, Grep]
|
||||
---
|
||||
|
||||
# ESN AI POS 데이터 분류·매핑 에이전트
|
||||
|
||||
## 핵심 역할
|
||||
`C:\GUARDiA\workspace\guardia-esn\backend\`(`com.zioinfo.esn`)에서 POS 원천 데이터(`esn_pos_cvt`)를 ESL 표시용 상품/가격(`esn_products`·`esn_tag_bindings`)으로 변환할 때, **중앙 guardia-rag `/structured`(format:json)** 로 결정론적 분류·필드 매핑·정규화를 보조한다. 분류 결과는 항상 고정 스키마 JSON으로 받아 사람·후속 배치가 신뢰할 수 있게 한다. 기존 `PosCvtService`는 보존하고 AI 분류 경로를 옵션으로 추가한다.
|
||||
|
||||
## 작업 원칙
|
||||
1. **결정론 우선** — 모든 AI 출력은 `/structured`(format:json) + 고정 JSON schema로 강제. 자유서술 금지, temperature 낮게. 동일 입력→동일 출력 보장.
|
||||
2. **분류 대상** — POS 품목명/코드 → 표준 카테고리, 단위/규격 정규화, 가격 필드(정상가/행사가/단위가) 매핑, 중복/오타 품목 후보 묶음.
|
||||
3. **매핑 보조(자동확정 아님)** — AI는 후보·신뢰도만 제시. 신뢰도 임계 미만은 `needsReview:true`로 사람 확인 큐에 남긴다(오매핑으로 잘못된 가격이 ESL에 표시되는 사고 방지).
|
||||
4. **근거 동반** — 매핑 판단의 근거(유사 기존 매핑·규칙)를 `/answer`(retrieval_mode) 검색으로 첨부, `/verify`로 근거검증.
|
||||
5. **온프레미스 + 폴백** — 중앙 실패 시 룰/사전 기반 매핑 폴백 + `degraded:true`. 미분류 항목은 버리지 않고 보류 큐로.
|
||||
|
||||
## 입력 / 출력
|
||||
- 입력: POS 행(또는 배치), `tenantCode`(LGINNOTEK/LGIT/EMART/ZIOINFO), 매핑 사전/카테고리 세트
|
||||
- 출력(고정 schema): `{ items: [{ posCode, mappedProductId, category, unit, priceFields{}, confidence, needsReview }], degraded }`
|
||||
- 중앙 계약: `/structured`(format:json 분류) · `/answer`(유사 매핑 검색) · `/verify`(매핑 근거) · `/feedback`(사람 교정→학습)
|
||||
|
||||
## 에러 핸들링
|
||||
- JSON 파싱 실패/스키마 불일치 → 재시도 후 룰 폴백, 해당 행 `needsReview:true`. 절대 임의 추정값을 확정 매핑으로 쓰지 않는다.
|
||||
- 중앙 무응답 → `degraded:true` + 룰 폴백. 사용자에게 요약 메시지만, 스택트레이스 미노출.
|
||||
- 가격/금액 필드는 숫자형 검증 통과 못 하면 매핑 보류(잘못된 가격 ESL 노출 차단).
|
||||
|
||||
## 팀 통신
|
||||
- 공통 배선·`/structured` 토글은 **esn-ai-applier** 와 협업(이 에이전트는 POS 도메인 규칙 담당).
|
||||
- 결정론(동일입력 동일출력)·테넌트 격리·외부 API 0 검증은 **esn-ai-qa** 에 의뢰.
|
||||
- 도메인/매퍼/스키마 변경은 **esn-backend-dev**, 검수 화면은 **esn-frontend-dev** 와 조율.
|
||||
|
||||
## 공통 불변 (위반 불가)
|
||||
- 외부 API 절대 금지 — Ollama(localhost:11434) 전용. 중앙 guardia-rag도 온프레미스만.
|
||||
- 자격증명·PII·스택트레이스 미노출. `passwordHash`·`ssh_*` 응답 제외.
|
||||
- 서버 RAM 제약 — 소형 모델 기본, 동시성 제한, 실패 시 `degraded` 폴백.
|
||||
- 테넌트 격리 — POS·상품 색인/조회는 `tenant_code` 필터 + 테넌트별 컬렉션 분리.
|
||||
- `@MapperScan(annotationClass = Mapper.class)` · Hikari `maximum-pool-size: 3` 준수.
|
||||
56
.claude/agents/esn-ai-qa.md
Normal file
56
.claude/agents/esn-ai-qa.md
Normal file
@ -0,0 +1,56 @@
|
||||
---
|
||||
name: esn-ai-qa
|
||||
description: GUARDiA ESN 최신 AI 기법 도입 검증 QA. 알람분석·POS분류·기법배선의 근거성·결정론·테넌트 격리·외부 API 0 경계면을 교차 검증한다. 근거 미달 시 답변 보류·인용 동반·결정론 JSON·degraded 폴백·서버 RAM 안전을 모듈 완성 직후 점진 확인하고 통과까지 반려한다. general-purpose 타입. "ESN AI 검증", "AI 기법 QA", "근거 검증", "결정론 검증", "테넌트 격리 검증", "다시 실행", "보완" 요청 시 사용.
|
||||
model: opus
|
||||
metadata:
|
||||
type: agent
|
||||
tools: [Read, Write, Edit, Bash, Glob, Grep]
|
||||
---
|
||||
|
||||
# ESN AI 기법 QA 에이전트 (general-purpose)
|
||||
|
||||
## 핵심 역할
|
||||
GUARDiA ESN(`C:\GUARDiA\workspace\guardia-esn\`, 포트 8016, `com.zioinfo.esn`)에 도입된 최신 AI 기법(알람 원인분석·POS 분류·기법 배선)을 **모듈 완성 직후 점진적으로** 검증한다. 중앙 guardia-rag 계약과 ESN 솔루션 측 배선의 경계면을 교차 비교하고, 근거·결정론·테넌트 격리·온프레미스(외부 API 0)·서버 RAM 안전을 확인해 통과까지 수정 지시를 반려한다.
|
||||
|
||||
## 작업 원칙 (검증 항목)
|
||||
1. **근거성(grounding)** — 근거 미달 시 실제로 답변이 보류/`I don't know`로 떨어지는지(환각 차단), 응답에 인용(citations)이 동반되는지. `/verify` 경유 확인.
|
||||
2. **결정론** — POS 분류 등 `/structured(format:json)` 경로가 동일 입력에 동일 JSON·고정 schema를 반환하는지(2회 호출 대조). 자유서술 누출 없는지.
|
||||
3. **에이전틱 완주** — `/agent` tool-use 루프가 무한루프 없이 종결하고 근본원인·우선순위를 반환하는지. GraphRAG 연쇄 알람이 단일 근본원인으로 수렴하는지.
|
||||
4. **테넌트 격리** — LGINNOTEK/LGIT/EMART/ZIOINFO 간 색인·검색·설정이 누설되지 않는지(다른 테넌트 토큰으로 교차 조회 시 차단).
|
||||
5. **온프레미스** — 코드·런타임에 외부 AI/HTTP 엔드포인트 호출 0. Ollama(localhost:11434)·중앙 guardia-rag(온프레미스)만.
|
||||
6. **폴백·RAM 안전** — 중앙/Ollama 실패 시 `degraded:true` 폴백 동작, 소형모델 기본·비전 자동로드 금지·동시성 제한 준수.
|
||||
7. **회귀·보안** — 기존 알람/POS/HCore 기능 회귀 없음, `passwordHash`·`ssh_*`·PII·스택트레이스·내부IP 미노출.
|
||||
|
||||
## 입력 / 출력
|
||||
- 입력: 완성된 모듈/엔드포인트, 변경 경로, 테넌트별 테스트 토큰
|
||||
- 출력: 항목별 PASS/FAIL + 재현 절차 + 수정 지시. FAIL 잔존 시 통과 반려.
|
||||
|
||||
## 검증 방법
|
||||
```bash
|
||||
# 로그인(테넌트별)
|
||||
curl -X POST http://localhost:8016/api/auth/login \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"username":"admin","password":"admin123"}'
|
||||
|
||||
# 결정론 — POS 분류 2회 호출 결과 동일성 대조
|
||||
# 테넌트 격리 — A 테넌트 토큰으로 B 테넌트 데이터 조회 시 차단 확인
|
||||
curl -H 'Authorization: Bearer {tokenA}' \
|
||||
'http://localhost:8016/api/...?tenantCode=LGIT' # 교차 차단되어야 함
|
||||
|
||||
# 외부 API 0 — 코드 정적 점검(Ollama/guardia-rag 외 외부 호출 없음)
|
||||
grep -rE 'https?://(?!localhost|127\.0\.0\.1)' backend/src/main || echo 외부호출없음
|
||||
```
|
||||
|
||||
## 에러 핸들링
|
||||
- 서버 다운 시 라이브 호출 불가 → 코드/계약 경계면 정적 검증으로 대체하고 라이브 검증 보류 사실을 보고.
|
||||
- 검증 과정에서 자격증명·PII 노출 발견 시 즉시 FAIL + 마스킹 수정 지시.
|
||||
|
||||
## 팀 통신
|
||||
- 알람 결과 정합 지시는 **esn-ai-alarm-agent**, 분류 결정론 지시는 **esn-ai-poscvt-agent**, 배선/토글/스트리밍 지시는 **esn-ai-applier** 에 반려.
|
||||
- 도메인/매퍼·UI 수정은 **esn-backend-dev**·**esn-frontend-dev**, 배포 재검증은 **esn-devops-dev** 와 조율.
|
||||
|
||||
## 공통 불변 (위반 불가)
|
||||
- 외부 API 절대 금지 — Ollama(localhost:11434)·중앙 guardia-rag(온프레미스) 외 호출 0 검증.
|
||||
- 자격증명·PII·스택트레이스·내부IP 미노출 검증. `passwordHash`·`ssh_*` 응답 제외 확인.
|
||||
- 서버 RAM 제약 — 소형모델 기본·비전 자동로드 금지·동시성 제한·`degraded` 폴백 동작 검증.
|
||||
- 테넌트 격리(LGInnotek/LGIT/Emart/ZIOINFO 컬렉션 분리) 누설 0 검증.
|
||||
@ -119,3 +119,11 @@ workspace/guardia-esn/
|
||||
- 배포 작업
|
||||
- 레거시 소스 분석·통합
|
||||
- 알람·POS·HCore 관련 작업
|
||||
|
||||
## AI 에이전트 (최신 기법 — 중앙 guardia-rag 연계)
|
||||
|
||||
`.claude/agents/` 의 도메인 맞춤 AI 에이전트 4종(외부 API 금지·Ollama 전용·테넌트 격리·소형모델 기본·degraded 폴백, 중앙 계약 `/answer`·`/verify`·`/agent`·`/structured`·`/feedback`):
|
||||
- `esn-ai-alarm-agent` — ESL/HCore 알람 원인분석·우선순위(tool-use + GraphRAG 의존성 추적)
|
||||
- `esn-ai-poscvt-agent` — POS 데이터 자동 분류·매핑 보조(`/structured` format:json 결정론)
|
||||
- `esn-ai-applier` — 최신 기법(hybrid/graph/rerank·tool-use·structured·스트리밍) ESN 적용·배선·기법 토글
|
||||
- `esn-ai-qa` — 근거·결정론·테넌트 격리·외부 API 0 경계면 검증(general-purpose)
|
||||
|
||||
@ -0,0 +1,76 @@
|
||||
package com.zioinfo.esn.admin;
|
||||
|
||||
import com.zioinfo.esn.admin.dto.AuditLog;
|
||||
import com.zioinfo.esn.admin.dto.EsnSetting;
|
||||
import com.zioinfo.esn.common.ApiResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* GUARDiA ESN 관리자 API — 감사 로그 조회 + 시스템 설정.
|
||||
*
|
||||
* <p>RBAC (SecurityConfig requestMatchers 로 통제):
|
||||
* <ul>
|
||||
* <li>GET /api/admin/audit — ADMIN, MANAGER</li>
|
||||
* <li>GET /api/admin/settings — ADMIN, MANAGER</li>
|
||||
* <li>PUT /api/admin/settings/** — ADMIN</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>사용자/테넌트/펌웨어 CRUD 는 기존 도메인 컨트롤러(UserController/TenantController/FirmwareController)
|
||||
* 가 담당한다. 이 컨트롤러는 admin 보조 영역(감사·설정)만 추가한다.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminController {
|
||||
|
||||
private final AuditService auditService;
|
||||
private final SettingService settingService;
|
||||
|
||||
// ===================== 감사 로그 (ADMIN/MANAGER) =====================
|
||||
|
||||
@GetMapping("/audit")
|
||||
public ApiResponse<List<AuditLog>> audit(
|
||||
@RequestParam(required = false) String tenantCode,
|
||||
@RequestParam(required = false) String username,
|
||||
@RequestParam(required = false) String action,
|
||||
@RequestParam(required = false) String resource,
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from,
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to,
|
||||
@RequestParam(defaultValue = "100") int limit) {
|
||||
return ApiResponse.ok(auditService.find(tenantCode, username, action, resource, from, to, limit));
|
||||
}
|
||||
|
||||
@GetMapping("/audit/count")
|
||||
public ApiResponse<Long> auditCount(
|
||||
@RequestParam(required = false) String tenantCode,
|
||||
@RequestParam(required = false) String username,
|
||||
@RequestParam(required = false) String action,
|
||||
@RequestParam(required = false) String resource,
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime from,
|
||||
@RequestParam(required = false)
|
||||
@DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime to) {
|
||||
return ApiResponse.ok(auditService.count(tenantCode, username, action, resource, from, to));
|
||||
}
|
||||
|
||||
// ===================== 시스템 설정 =====================
|
||||
|
||||
@GetMapping("/settings")
|
||||
public ApiResponse<List<EsnSetting>> settings(@RequestParam(required = false) String category) {
|
||||
return ApiResponse.ok(settingService.list(category));
|
||||
}
|
||||
|
||||
@PutMapping("/settings/{key}")
|
||||
public ApiResponse<EsnSetting> updateSetting(@PathVariable String key,
|
||||
@RequestBody Map<String, String> body) {
|
||||
return ApiResponse.ok("설정 변경 완료", settingService.update(key, body.get("value")));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,99 @@
|
||||
package com.zioinfo.esn.admin;
|
||||
|
||||
import com.zioinfo.esn.admin.dto.AuditLog;
|
||||
import com.zioinfo.esn.admin.mapper.AuditLogMapper;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.context.request.RequestContextHolder;
|
||||
import org.springframework.web.context.request.ServletRequestAttributes;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 감사 로그 서비스.
|
||||
*
|
||||
* <p>ESL 제어 운영(펌웨어 배포·POS 가격 변경·태그 큐 전파)과 관리 행위(사용자/테넌트 CRUD·설정 변경)를
|
||||
* {@code esn_audit_log} 에 기록하고 조회한다.
|
||||
*
|
||||
* <p>감사 기록 실패가 본업(펌웨어 배포 등)을 막아서는 안 되므로 insert 는 best-effort(예외 흡수)다.
|
||||
*
|
||||
* <p>보안 불변: 비밀번호/자격증명/PII 는 detail 에 절대 기록하지 않는다. 호출 측이 키/식별자/요약만 넘긴다.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuditService {
|
||||
|
||||
private final AuditLogMapper mapper;
|
||||
|
||||
/** 가장 단순한 형태: 액션/리소스/식별자/요약. 현재 인증 주체로 기록. */
|
||||
public void log(String action, String resource, String resourceId, String detail) {
|
||||
log(null, action, resource, resourceId, detail, true);
|
||||
}
|
||||
|
||||
/** 테넌트 스코프 포함. */
|
||||
public void log(String tenantCode, String action, String resource, String resourceId,
|
||||
String detail, boolean success) {
|
||||
try {
|
||||
AuditLog entry = new AuditLog();
|
||||
entry.setTenantCode(tenantCode);
|
||||
entry.setUsername(currentActor());
|
||||
entry.setAction(action);
|
||||
entry.setResource(resource);
|
||||
entry.setResourceId(resourceId);
|
||||
entry.setDetail(detail);
|
||||
entry.setSuccess(success);
|
||||
HttpServletRequest req = currentRequest();
|
||||
if (req != null) {
|
||||
entry.setIpAddress(clientIp(req));
|
||||
String ua = req.getHeader("User-Agent");
|
||||
entry.setUserAgent(ua != null && ua.length() > 500 ? ua.substring(0, 500) : ua);
|
||||
}
|
||||
mapper.insert(entry);
|
||||
} catch (Exception e) {
|
||||
// best-effort: 감사 기록 실패가 본업을 막지 않는다.
|
||||
log.warn("audit log insert 실패 action={} resource={}: {}", action, resource, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
public List<AuditLog> find(String tenantCode, String username, String action, String resource,
|
||||
LocalDateTime from, LocalDateTime to, int limit) {
|
||||
int safeLimit = (limit <= 0 || limit > 1000) ? 100 : limit;
|
||||
return mapper.find(tenantCode, username, action, resource, from, to, safeLimit);
|
||||
}
|
||||
|
||||
public long count(String tenantCode, String username, String action, String resource,
|
||||
LocalDateTime from, LocalDateTime to) {
|
||||
return mapper.count(tenantCode, username, action, resource, from, to);
|
||||
}
|
||||
|
||||
/** SecurityContext 의 JWT subject(username). 없으면 system. */
|
||||
public static String currentActor() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth != null && auth.getName() != null && !auth.getName().isBlank()) {
|
||||
return auth.getName();
|
||||
}
|
||||
return "system";
|
||||
}
|
||||
|
||||
private static HttpServletRequest currentRequest() {
|
||||
var attrs = RequestContextHolder.getRequestAttributes();
|
||||
if (attrs instanceof ServletRequestAttributes sra) {
|
||||
return sra.getRequest();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static String clientIp(HttpServletRequest req) {
|
||||
String xf = req.getHeader("X-Forwarded-For");
|
||||
if (xf != null && !xf.isBlank()) {
|
||||
return xf.split(",")[0].trim();
|
||||
}
|
||||
return req.getRemoteAddr();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,43 @@
|
||||
package com.zioinfo.esn.admin;
|
||||
|
||||
import com.zioinfo.esn.admin.dto.EsnSetting;
|
||||
import com.zioinfo.esn.admin.mapper.SettingMapper;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 시스템 설정 서비스.
|
||||
*
|
||||
* <p>조회는 ADMIN/MANAGER, 변경은 ADMIN (SecurityConfig requestMatchers 로 통제).
|
||||
* 변경 시 감사 로그에 이전→이후 요약을 기록한다(값은 민감하지 않은 운영 설정만 다룬다).
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class SettingService {
|
||||
|
||||
private final SettingMapper mapper;
|
||||
private final AuditService auditService;
|
||||
|
||||
public List<EsnSetting> list(String category) {
|
||||
return mapper.findAll(category);
|
||||
}
|
||||
|
||||
public EsnSetting update(String key, String value) {
|
||||
if (key == null || key.isBlank()) {
|
||||
throw new IllegalArgumentException("ERR-SET-400: key 필수");
|
||||
}
|
||||
if (value == null) {
|
||||
throw new IllegalArgumentException("ERR-SET-400: value 필수");
|
||||
}
|
||||
EsnSetting before = mapper.findByKey(key);
|
||||
String category = before == null ? "GENERAL" : before.getCategory();
|
||||
String description = before == null ? null : before.getDescription();
|
||||
mapper.upsert(key, value, category, description);
|
||||
String prev = before == null ? "(none)" : before.getSettingValue();
|
||||
auditService.log(null, "SETTING_CHANGE", "SETTING", key,
|
||||
prev + " -> " + value, true);
|
||||
return mapper.findByKey(key);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,28 @@
|
||||
package com.zioinfo.esn.admin.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 감사 로그 (esn_audit_log 테이블 매핑).
|
||||
*
|
||||
* <p>ESL 제어 운영(펌웨어 배포·가격 변경·태그 큐 전파)과 관리 행위(사용자/테넌트 CRUD·설정 변경)를
|
||||
* 추적한다. 멀티테넌트 스코프(tenantCode)를 보존한다.
|
||||
*
|
||||
* <p>보안: 비밀번호/자격증명/PII 는 detail 에 절대 기록하지 않는다(키/식별자/요약만).
|
||||
*/
|
||||
@Data
|
||||
public class AuditLog {
|
||||
private Long id;
|
||||
private String tenantCode; // 테넌트 스코프 (nullable)
|
||||
private String username; // 작업 수행자 (JWT subject)
|
||||
private String action; // USER_CREATE, FIRMWARE_DEPLOY, POS_PRICE_CHANGE, SETTING_CHANGE 등
|
||||
private String resource; // 대상 리소스 종류 (USER / FIRMWARE / POS_CVT / TENANT / SETTING ...)
|
||||
private String resourceId; // 대상 식별자 (id / key 등)
|
||||
private String detail; // 부가 요약 (민감정보 제외)
|
||||
private String ipAddress; // 요청 IP (nullable)
|
||||
private String userAgent; // User-Agent (nullable)
|
||||
private Boolean success; // 성공 여부
|
||||
private LocalDateTime createdAt;
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
package com.zioinfo.esn.admin.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 시스템 설정 (esn_setting 테이블 매핑).
|
||||
*
|
||||
* <p>조회는 ADMIN/MANAGER, 변경은 ADMIN (SecurityConfig requestMatchers 로 통제).
|
||||
* 비밀번호/자격증명 같은 민감 설정은 저장하지 않는다(그런 값은 CryptoUtil 암호화 경로 사용).
|
||||
*/
|
||||
@Data
|
||||
public class EsnSetting {
|
||||
private String settingKey;
|
||||
private String settingValue;
|
||||
private String category; // GENERAL / ESL / ALARM / AI ...
|
||||
private String description;
|
||||
private LocalDateTime updatedAt;
|
||||
}
|
||||
@ -0,0 +1,33 @@
|
||||
package com.zioinfo.esn.admin.mapper;
|
||||
|
||||
import com.zioinfo.esn.admin.dto.AuditLog;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 감사 로그 매퍼. @Mapper 어노테이션 필수
|
||||
* (EsnApplication 의 @MapperScan(annotationClass = Mapper.class) 패턴).
|
||||
*/
|
||||
@Mapper
|
||||
public interface AuditLogMapper {
|
||||
|
||||
int insert(AuditLog log);
|
||||
|
||||
List<AuditLog> find(@Param("tenantCode") String tenantCode,
|
||||
@Param("username") String username,
|
||||
@Param("action") String action,
|
||||
@Param("resource") String resource,
|
||||
@Param("from") LocalDateTime from,
|
||||
@Param("to") LocalDateTime to,
|
||||
@Param("limit") int limit);
|
||||
|
||||
long count(@Param("tenantCode") String tenantCode,
|
||||
@Param("username") String username,
|
||||
@Param("action") String action,
|
||||
@Param("resource") String resource,
|
||||
@Param("from") LocalDateTime from,
|
||||
@Param("to") LocalDateTime to);
|
||||
}
|
||||
@ -0,0 +1,25 @@
|
||||
package com.zioinfo.esn.admin.mapper;
|
||||
|
||||
import com.zioinfo.esn.admin.dto.EsnSetting;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
import org.apache.ibatis.annotations.Param;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 시스템 설정 매퍼. @Mapper 어노테이션 필수
|
||||
* (EsnApplication 의 @MapperScan(annotationClass = Mapper.class) 패턴).
|
||||
*/
|
||||
@Mapper
|
||||
public interface SettingMapper {
|
||||
|
||||
List<EsnSetting> findAll(@Param("category") String category);
|
||||
|
||||
EsnSetting findByKey(@Param("settingKey") String settingKey);
|
||||
|
||||
/** upsert (ON CONFLICT). description/category 는 신규 INSERT 시에만 사용. */
|
||||
int upsert(@Param("settingKey") String settingKey,
|
||||
@Param("settingValue") String settingValue,
|
||||
@Param("category") String category,
|
||||
@Param("description") String description);
|
||||
}
|
||||
@ -44,6 +44,10 @@ public class SecurityConfig {
|
||||
.requestMatchers("/api/tenants/**").hasRole("ADMIN")
|
||||
.requestMatchers("/api/users/**").hasAnyRole("ADMIN", "MANAGER")
|
||||
.requestMatchers("/api/firmware/**").hasAnyRole("ADMIN", "MANAGER")
|
||||
// admin 보조: 감사로그 조회·설정 조회 = ADMIN/MANAGER, 설정 변경 = ADMIN
|
||||
// (아래 일반 PUT /api/** 규칙보다 먼저 평가되도록 상단 배치)
|
||||
.requestMatchers(HttpMethod.PUT, "/api/admin/settings/**").hasRole("ADMIN")
|
||||
.requestMatchers("/api/admin/**").hasAnyRole("ADMIN", "MANAGER")
|
||||
.requestMatchers(HttpMethod.DELETE, "/api/**").hasAnyRole("ADMIN", "MANAGER")
|
||||
.requestMatchers(HttpMethod.POST, "/api/**").hasAnyRole("ADMIN", "MANAGER", "USER")
|
||||
.requestMatchers(HttpMethod.PUT, "/api/**").hasAnyRole("ADMIN", "MANAGER", "USER")
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package com.zioinfo.esn.controller;
|
||||
|
||||
import com.zioinfo.esn.admin.AuditService;
|
||||
import com.zioinfo.esn.common.ApiResponse;
|
||||
import com.zioinfo.esn.domain.FirmwareVo;
|
||||
import com.zioinfo.esn.service.FirmwareService;
|
||||
@ -12,6 +13,7 @@ import java.util.List;
|
||||
@RequiredArgsConstructor
|
||||
public class FirmwareController {
|
||||
private final FirmwareService service;
|
||||
private final AuditService audit;
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<List<FirmwareVo>> list(
|
||||
@ -27,17 +29,27 @@ public class FirmwareController {
|
||||
|
||||
@PostMapping
|
||||
public ApiResponse<FirmwareVo> create(@RequestBody FirmwareVo v) {
|
||||
return ApiResponse.ok("펌웨어 등록 완료", service.create(v));
|
||||
FirmwareVo created = service.create(v);
|
||||
// ESL 펌웨어 배포 — 추적 가치 높음
|
||||
audit.log(created.getTenantCode(), "FIRMWARE_DEPLOY", "FIRMWARE", String.valueOf(created.getId()),
|
||||
"deviceType=" + created.getDeviceType() + ", version=" + created.getFirmwareVersion()
|
||||
+ ", latest=" + created.isLatest(), true);
|
||||
return ApiResponse.ok("펌웨어 등록 완료", created);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ApiResponse<FirmwareVo> update(@PathVariable Long id, @RequestBody FirmwareVo v) {
|
||||
return ApiResponse.ok("펌웨어 수정 완료", service.update(id, v));
|
||||
FirmwareVo updated = service.update(id, v);
|
||||
audit.log(updated.getTenantCode(), "FIRMWARE_UPDATE", "FIRMWARE", String.valueOf(id),
|
||||
"deviceType=" + updated.getDeviceType() + ", version=" + updated.getFirmwareVersion()
|
||||
+ ", latest=" + updated.isLatest(), true);
|
||||
return ApiResponse.ok("펌웨어 수정 완료", updated);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ApiResponse<Void> delete(@PathVariable Long id) {
|
||||
service.delete(id);
|
||||
audit.log(null, "FIRMWARE_DELETE", "FIRMWARE", String.valueOf(id), "펌웨어 삭제", true);
|
||||
return ApiResponse.ok("펌웨어 삭제 완료", null);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package com.zioinfo.esn.controller;
|
||||
|
||||
import com.zioinfo.esn.admin.AuditService;
|
||||
import com.zioinfo.esn.common.ApiResponse;
|
||||
import com.zioinfo.esn.domain.PosCvtVo;
|
||||
import com.zioinfo.esn.service.PosCvtService;
|
||||
@ -13,6 +14,7 @@ import java.util.Map;
|
||||
@RequiredArgsConstructor
|
||||
public class PosCvtController {
|
||||
private final PosCvtService service;
|
||||
private final AuditService audit;
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<List<PosCvtVo>> list(
|
||||
@ -30,7 +32,13 @@ public class PosCvtController {
|
||||
|
||||
@PutMapping("/{id}/process")
|
||||
public ApiResponse<Integer> process(@PathVariable Long id) {
|
||||
PosCvtVo pos = service.get(id);
|
||||
int queued = service.process(id);
|
||||
// ESL 가격 변경 전파 — 추적 가치 높음 (가격은 영업정보이나 민감개인정보 아님)
|
||||
String detail = pos == null ? ("queued=" + queued)
|
||||
: ("product=" + pos.getProductCode() + ", price=" + pos.getPrice() + ", queued=" + queued);
|
||||
audit.log(pos != null ? pos.getTenantCode() : null, "POS_PRICE_CHANGE", "POS_CVT",
|
||||
String.valueOf(id), detail, true);
|
||||
return ApiResponse.ok("처리 완료 (태그 큐 " + queued + "건)", queued);
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package com.zioinfo.esn.controller;
|
||||
|
||||
import com.zioinfo.esn.admin.AuditService;
|
||||
import com.zioinfo.esn.common.ApiResponse;
|
||||
import com.zioinfo.esn.domain.TenantVo;
|
||||
import com.zioinfo.esn.service.TenantService;
|
||||
@ -12,6 +13,7 @@ import java.util.List;
|
||||
@RequiredArgsConstructor
|
||||
public class TenantController {
|
||||
private final TenantService service;
|
||||
private final AuditService audit;
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<List<TenantVo>> list() {
|
||||
@ -25,17 +27,24 @@ public class TenantController {
|
||||
|
||||
@PostMapping
|
||||
public ApiResponse<TenantVo> create(@RequestBody TenantVo v) {
|
||||
return ApiResponse.ok("테넌트 생성 완료", service.create(v));
|
||||
TenantVo created = service.create(v);
|
||||
audit.log(created.getTenantCode(), "TENANT_CREATE", "TENANT", String.valueOf(created.getId()),
|
||||
"name=" + created.getTenantName(), true);
|
||||
return ApiResponse.ok("테넌트 생성 완료", created);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ApiResponse<TenantVo> update(@PathVariable Long id, @RequestBody TenantVo v) {
|
||||
return ApiResponse.ok("테넌트 수정 완료", service.update(id, v));
|
||||
TenantVo updated = service.update(id, v);
|
||||
audit.log(updated.getTenantCode(), "TENANT_UPDATE", "TENANT", String.valueOf(id),
|
||||
"name=" + updated.getTenantName(), true);
|
||||
return ApiResponse.ok("테넌트 수정 완료", updated);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ApiResponse<Void> delete(@PathVariable Long id) {
|
||||
service.delete(id);
|
||||
audit.log(null, "TENANT_DELETE", "TENANT", String.valueOf(id), "테넌트 삭제", true);
|
||||
return ApiResponse.ok("테넌트 삭제 완료", null);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
package com.zioinfo.esn.controller;
|
||||
|
||||
import com.zioinfo.esn.admin.AuditService;
|
||||
import com.zioinfo.esn.auth.EsnUser;
|
||||
import com.zioinfo.esn.common.ApiResponse;
|
||||
import com.zioinfo.esn.service.UserService;
|
||||
@ -13,6 +14,7 @@ import java.util.Map;
|
||||
@RequiredArgsConstructor
|
||||
public class UserController {
|
||||
private final UserService service;
|
||||
private final AuditService audit;
|
||||
|
||||
@GetMapping
|
||||
public ApiResponse<List<EsnUser>> list(
|
||||
@ -36,23 +38,42 @@ public class UserController {
|
||||
user.setPhone((String) body.get("phone"));
|
||||
user.setActive(true);
|
||||
String rawPassword = (String) body.getOrDefault("password", "changeme123");
|
||||
return ApiResponse.ok("사용자 생성 완료", service.create(user, rawPassword));
|
||||
EsnUser created = service.create(user, rawPassword);
|
||||
// 보안: 비밀번호는 감사 detail 에 절대 기록하지 않는다(username/role/tenant 요약만).
|
||||
audit.log(created.getTenantCode(), "USER_CREATE", "USER", String.valueOf(created.getId()),
|
||||
"username=" + created.getUsername() + ", role=" + created.getRole(), true);
|
||||
return ApiResponse.ok("사용자 생성 완료", created);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}")
|
||||
public ApiResponse<EsnUser> update(@PathVariable Long id, @RequestBody EsnUser v) {
|
||||
return ApiResponse.ok("사용자 수정 완료", service.update(id, v));
|
||||
EsnUser updated = service.update(id, v);
|
||||
audit.log(updated.getTenantCode(), "USER_UPDATE", "USER", String.valueOf(id),
|
||||
"role=" + updated.getRole() + ", active=" + updated.isActive(), true);
|
||||
return ApiResponse.ok("사용자 수정 완료", updated);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/password")
|
||||
public ApiResponse<Void> changePassword(@PathVariable Long id, @RequestBody Map<String, String> body) {
|
||||
service.changePassword(id, body.getOrDefault("password", "changeme123"));
|
||||
// 보안: 새 비밀번호 값은 기록하지 않는다(대상 id 만).
|
||||
audit.log(null, "USER_PASSWORD_RESET", "USER", String.valueOf(id), "비밀번호 초기화", true);
|
||||
return ApiResponse.ok("비밀번호 변경 완료", null);
|
||||
}
|
||||
|
||||
@PutMapping("/{id}/active")
|
||||
public ApiResponse<EsnUser> setActive(@PathVariable Long id, @RequestBody Map<String, Object> body) {
|
||||
boolean active = Boolean.TRUE.equals(body.get("active"));
|
||||
EsnUser u = service.setActive(id, active);
|
||||
audit.log(u != null ? u.getTenantCode() : null, "USER_ACTIVE_TOGGLE", "USER",
|
||||
String.valueOf(id), "active=" + active, true);
|
||||
return ApiResponse.ok(active ? "활성화 완료" : "비활성화 완료", u);
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ApiResponse<Void> delete(@PathVariable Long id) {
|
||||
service.delete(id);
|
||||
audit.log(null, "USER_DELETE", "USER", String.valueOf(id), "사용자 삭제", true);
|
||||
return ApiResponse.ok("사용자 삭제 완료", null);
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,5 +35,13 @@ public class UserService {
|
||||
mapper.updatePassword(id, passwordEncoder.encode(rawPassword));
|
||||
}
|
||||
|
||||
public EsnUser setActive(Long id, boolean active) {
|
||||
EsnUser u = mapper.findById(id);
|
||||
if (u == null) return null;
|
||||
u.setActive(active);
|
||||
mapper.update(u);
|
||||
return mapper.findById(id);
|
||||
}
|
||||
|
||||
public void delete(Long id) { mapper.delete(id); }
|
||||
}
|
||||
|
||||
@ -331,3 +331,25 @@ CREATE TABLE IF NOT EXISTS esn_audit_log (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_created ON esn_audit_log(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_tenant ON esn_audit_log(tenant_code, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_action ON esn_audit_log(action, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_user ON esn_audit_log(username, created_at DESC);
|
||||
|
||||
-- ── 시스템 설정 (admin 보조) ──────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS esn_setting (
|
||||
setting_key VARCHAR(100) PRIMARY KEY,
|
||||
setting_value TEXT,
|
||||
category VARCHAR(50) DEFAULT 'GENERAL',
|
||||
description VARCHAR(300),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 기본 운영 설정 시드 (멱등 — ON CONFLICT DO NOTHING)
|
||||
INSERT INTO esn_setting (setting_key, setting_value, category, description) VALUES
|
||||
('system.name', 'GUARDiA ESN', 'GENERAL', '시스템 표시명'),
|
||||
('esl.update.poll.limit', '50', 'ESL', 'Gateway 폴링 1회 최대 큐 건수'),
|
||||
('esl.update.retry.max', '3', 'ESL', '태그 전송 실패 재시도 횟수'),
|
||||
('alarm.retention.days', '90', 'ALARM', '알람 이력 보존 일수'),
|
||||
('audit.retention.days', '365', 'GENERAL', '감사 로그 보존 일수'),
|
||||
('ai.ollama.text.model', 'llama3.2:1b', 'AI', 'Ollama 텍스트 모델'),
|
||||
('ai.ollama.enabled', 'true', 'AI', 'AI 분석 기능 사용 여부')
|
||||
ON CONFLICT (setting_key) DO NOTHING;
|
||||
|
||||
55
backend/src/main/resources/mapper/AuditLogMapper.xml
Normal file
55
backend/src/main/resources/mapper/AuditLogMapper.xml
Normal file
@ -0,0 +1,55 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.zioinfo.esn.admin.mapper.AuditLogMapper">
|
||||
|
||||
<resultMap id="auditMap" type="com.zioinfo.esn.admin.dto.AuditLog">
|
||||
<id property="id" column="id"/>
|
||||
<result property="tenantCode" column="tenant_code"/>
|
||||
<result property="username" column="username"/>
|
||||
<result property="action" column="action"/>
|
||||
<result property="resource" column="resource"/>
|
||||
<result property="resourceId" column="resource_id"/>
|
||||
<result property="detail" column="detail"/>
|
||||
<result property="ipAddress" column="ip_address"/>
|
||||
<result property="userAgent" column="user_agent"/>
|
||||
<result property="success" column="success"/>
|
||||
<result property="createdAt" column="created_at"/>
|
||||
</resultMap>
|
||||
|
||||
<insert id="insert" parameterType="com.zioinfo.esn.admin.dto.AuditLog"
|
||||
useGeneratedKeys="true" keyProperty="id">
|
||||
INSERT INTO esn_audit_log
|
||||
(tenant_code, username, action, resource, resource_id, detail, ip_address, user_agent, success)
|
||||
VALUES
|
||||
(#{tenantCode}, #{username}, #{action}, #{resource}, #{resourceId}, #{detail},
|
||||
#{ipAddress}, #{userAgent}, #{success})
|
||||
</insert>
|
||||
|
||||
<sql id="filters">
|
||||
<where>
|
||||
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
|
||||
<if test="username != null and username != ''">AND username = #{username}</if>
|
||||
<if test="action != null and action != ''">AND action = #{action}</if>
|
||||
<if test="resource != null and resource != ''">AND resource = #{resource}</if>
|
||||
<if test="from != null">AND created_at >= #{from}</if>
|
||||
<if test="to != null">AND created_at <= #{to}</if>
|
||||
</where>
|
||||
</sql>
|
||||
|
||||
<select id="find" resultMap="auditMap">
|
||||
SELECT id, tenant_code, username, action, resource, resource_id, detail,
|
||||
ip_address, user_agent, success, created_at
|
||||
FROM esn_audit_log
|
||||
<include refid="filters"/>
|
||||
ORDER BY id DESC
|
||||
LIMIT #{limit}
|
||||
</select>
|
||||
|
||||
<select id="count" resultType="long">
|
||||
SELECT COUNT(*)
|
||||
FROM esn_audit_log
|
||||
<include refid="filters"/>
|
||||
</select>
|
||||
|
||||
</mapper>
|
||||
37
backend/src/main/resources/mapper/SettingMapper.xml
Normal file
37
backend/src/main/resources/mapper/SettingMapper.xml
Normal file
@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
"https://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||
<mapper namespace="com.zioinfo.esn.admin.mapper.SettingMapper">
|
||||
|
||||
<resultMap id="settingMap" type="com.zioinfo.esn.admin.dto.EsnSetting">
|
||||
<id property="settingKey" column="setting_key"/>
|
||||
<result property="settingValue" column="setting_value"/>
|
||||
<result property="category" column="category"/>
|
||||
<result property="description" column="description"/>
|
||||
<result property="updatedAt" column="updated_at"/>
|
||||
</resultMap>
|
||||
|
||||
<select id="findAll" resultMap="settingMap">
|
||||
SELECT setting_key, setting_value, category, description, updated_at
|
||||
FROM esn_setting
|
||||
<where>
|
||||
<if test="category != null and category != ''">AND category = #{category}</if>
|
||||
</where>
|
||||
ORDER BY category ASC, setting_key ASC
|
||||
</select>
|
||||
|
||||
<select id="findByKey" resultMap="settingMap">
|
||||
SELECT setting_key, setting_value, category, description, updated_at
|
||||
FROM esn_setting
|
||||
WHERE setting_key = #{settingKey}
|
||||
</select>
|
||||
|
||||
<update id="upsert">
|
||||
INSERT INTO esn_setting (setting_key, setting_value, category, description, updated_at)
|
||||
VALUES (#{settingKey}, #{settingValue}, #{category}, #{description}, NOW())
|
||||
ON CONFLICT (setting_key) DO UPDATE
|
||||
SET setting_value = EXCLUDED.setting_value,
|
||||
updated_at = NOW()
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
352
backend/src/main/resources/static/assets/index-B4CAJSnh.js
Normal file
352
backend/src/main/resources/static/assets/index-B4CAJSnh.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
362
backend/src/main/resources/static/assets/index-CYbjlaIi.js
Normal file
362
backend/src/main/resources/static/assets/index-CYbjlaIi.js
Normal file
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
14
backend/src/main/resources/static/index.html
Normal file
14
backend/src/main/resources/static/index.html
Normal file
@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>zioinfo-esn — ESL 통합 관리 플랫폼</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<script type="module" crossorigin src="/assets/index-CYbjlaIi.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-Bpv8yNZf.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
</body>
|
||||
</html>
|
||||
BIN
backend/src/main/resources/static/login.png
Normal file
BIN
backend/src/main/resources/static/login.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
@ -16,6 +16,12 @@ import TenantAdmin from './pages/TenantAdmin'
|
||||
import AiAnalysis from './pages/AiAnalysis'
|
||||
import TagBindingList from './pages/TagBindingList'
|
||||
import UpdateQueueList from './pages/UpdateQueueList'
|
||||
import AuditLog from './pages/AuditLog'
|
||||
import SystemSettings from './pages/SystemSettings'
|
||||
import WorklogList from './pages/uiws/WorklogList'
|
||||
import ScheduleCalendar from './pages/uiws/ScheduleCalendar'
|
||||
import MessageBox from './pages/uiws/MessageBox'
|
||||
import StatsPivot from './pages/uiws/StatsPivot'
|
||||
|
||||
const qc = new QueryClient()
|
||||
|
||||
@ -39,7 +45,13 @@ export default function App() {
|
||||
<Route path="/products" element={<ProductList />} />
|
||||
<Route path="/users" element={<UserList />} />
|
||||
<Route path="/tenants" element={<TenantAdmin />} />
|
||||
<Route path="/audit" element={<AuditLog />} />
|
||||
<Route path="/settings" element={<SystemSettings />} />
|
||||
<Route path="/ai" element={<AiAnalysis />} />
|
||||
<Route path="/worklogs" element={<WorklogList />} />
|
||||
<Route path="/schedules" element={<ScheduleCalendar />} />
|
||||
<Route path="/messages" element={<MessageBox />} />
|
||||
<Route path="/work-stats" element={<StatsPivot />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
|
||||
@ -99,6 +99,20 @@ export const createProduct = (d: object) => unwrap(api.post('/api/product
|
||||
export const updateProduct = (id: number, d: object) => unwrap(api.put(`/api/products/${id}`, d))
|
||||
export const deleteProduct = (id: number) => api.delete(`/api/products/${id}`)
|
||||
|
||||
// ── Admin: 감사 로그 ───────────────────────────────────────────────────────
|
||||
export const getAuditLogs = (params?: object) => unwrap(api.get('/api/admin/audit', { params }))
|
||||
export const getAuditCount = (params?: object) => unwrap(api.get('/api/admin/audit/count', { params }))
|
||||
|
||||
// ── Admin: 시스템 설정 ─────────────────────────────────────────────────────
|
||||
export const getSettings = (category?: string) =>
|
||||
unwrap(api.get('/api/admin/settings', { params: category ? { category } : {} }))
|
||||
export const updateSetting = (key: string, value: string) =>
|
||||
unwrap(api.put(`/api/admin/settings/${encodeURIComponent(key)}`, { value }))
|
||||
|
||||
// ── Users (active toggle) ──────────────────────────────────────────────────
|
||||
export const setUserActive = (id: number, active: boolean) =>
|
||||
unwrap(api.put(`/api/users/${id}/active`, { active }))
|
||||
|
||||
// ── AI ───────────────────────────────────────────────────────────────────
|
||||
export const analyzeAlarm = (d: object) => unwrap(api.post('/api/ai/analyze-alarm', d))
|
||||
export const classifyPos = (d: object) => unwrap(api.post('/api/ai/classify-pos', d))
|
||||
|
||||
@ -2,7 +2,8 @@ import { NavLink } from 'react-router-dom'
|
||||
import {
|
||||
LayoutDashboard, Store, FileText, RefreshCw,
|
||||
Bell, Cpu, ClipboardList, Zap, Users, Package, Building2, Brain,
|
||||
Link2, ListOrdered
|
||||
Link2, ListOrdered, NotebookPen, CalendarDays, Mail, BarChart3,
|
||||
ScrollText, Settings
|
||||
} from 'lucide-react'
|
||||
|
||||
const nav = [
|
||||
@ -19,7 +20,13 @@ const nav = [
|
||||
{ to: '/products', icon: Package, label: '상품/가격' },
|
||||
{ to: '/users', icon: Users, label: '사용자' },
|
||||
{ to: '/tenants', icon: Building2, label: '테넌트 관리' },
|
||||
{ to: '/audit', icon: ScrollText, label: '감사 로그' },
|
||||
{ to: '/settings', icon: Settings, label: '시스템 설정' },
|
||||
{ to: '/ai', icon: Brain, label: 'AI 분석' },
|
||||
{ to: '/worklogs', icon: NotebookPen, label: '업무일지' },
|
||||
{ to: '/schedules', icon: CalendarDays, label: '일정 관리' },
|
||||
{ to: '/messages', icon: Mail, label: '쪽지' },
|
||||
{ to: '/work-stats', icon: BarChart3, label: '업무 통계' },
|
||||
]
|
||||
|
||||
export default function Sidebar() {
|
||||
|
||||
149
frontend/src/pages/AuditLog.tsx
Normal file
149
frontend/src/pages/AuditLog.tsx
Normal file
@ -0,0 +1,149 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { getAuditLogs } from '../api/client'
|
||||
|
||||
const actionColor: Record<string, string> = {
|
||||
FIRMWARE_DEPLOY: 'bg-purple-500/20 text-purple-300',
|
||||
FIRMWARE_UPDATE: 'bg-purple-500/20 text-purple-300',
|
||||
FIRMWARE_DELETE: 'bg-red-500/20 text-red-400',
|
||||
POS_PRICE_CHANGE: 'bg-amber-500/20 text-amber-300',
|
||||
USER_CREATE: 'bg-green-500/20 text-green-400',
|
||||
USER_UPDATE: 'bg-blue-500/20 text-blue-400',
|
||||
USER_DELETE: 'bg-red-500/20 text-red-400',
|
||||
USER_PASSWORD_RESET:'bg-blue-500/20 text-blue-400',
|
||||
USER_ACTIVE_TOGGLE: 'bg-blue-500/20 text-blue-400',
|
||||
TENANT_CREATE: 'bg-green-500/20 text-green-400',
|
||||
TENANT_UPDATE: 'bg-blue-500/20 text-blue-400',
|
||||
TENANT_DELETE: 'bg-red-500/20 text-red-400',
|
||||
SETTING_CHANGE: 'bg-cyan-500/20 text-cyan-300',
|
||||
}
|
||||
|
||||
const ACTIONS = [
|
||||
'', 'FIRMWARE_DEPLOY', 'FIRMWARE_UPDATE', 'FIRMWARE_DELETE',
|
||||
'POS_PRICE_CHANGE', 'USER_CREATE', 'USER_UPDATE', 'USER_DELETE',
|
||||
'USER_PASSWORD_RESET', 'USER_ACTIVE_TOGGLE',
|
||||
'TENANT_CREATE', 'TENANT_UPDATE', 'TENANT_DELETE', 'SETTING_CHANGE',
|
||||
]
|
||||
|
||||
export default function AuditLog() {
|
||||
const [action, setAction] = useState('')
|
||||
const [username, setUsername] = useState('')
|
||||
const [tenantCode, setTenantCode] = useState('')
|
||||
const [from, setFrom] = useState('')
|
||||
const [to, setTo] = useState('')
|
||||
const [applied, setApplied] = useState<Record<string, any>>({ limit: 200 })
|
||||
|
||||
const { data: logs = [], isLoading, refetch, isFetching } = useQuery({
|
||||
queryKey: ['audit', applied],
|
||||
queryFn: () => getAuditLogs(applied),
|
||||
})
|
||||
|
||||
const search = () => {
|
||||
const p: Record<string, any> = { limit: 200 }
|
||||
if (action) p.action = action
|
||||
if (username.trim()) p.username = username.trim()
|
||||
if (tenantCode.trim()) p.tenantCode = tenantCode.trim()
|
||||
if (from) p.from = from + ':00'
|
||||
if (to) p.to = to + ':59'
|
||||
setApplied(p)
|
||||
}
|
||||
|
||||
const reset = () => {
|
||||
setAction(''); setUsername(''); setTenantCode(''); setFrom(''); setTo('')
|
||||
setApplied({ limit: 200 })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-white">감사 로그</h1>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="text-xs px-3 py-1.5 rounded bg-edge/60 text-gray-300 hover:bg-edge">
|
||||
{isFetching ? '조회 중...' : '새로고침'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 필터 */}
|
||||
<div className="bg-card border border-edge rounded-lg p-4 grid grid-cols-1 md:grid-cols-6 gap-3 items-end">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-gray-500">액션</label>
|
||||
<select value={action} onChange={e => setAction(e.target.value)}
|
||||
className="bg-panel border border-edge rounded px-2 py-1.5 text-sm text-gray-200">
|
||||
{ACTIONS.map(a => <option key={a} value={a}>{a || '전체'}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-gray-500">수행자</label>
|
||||
<input value={username} onChange={e => setUsername(e.target.value)} placeholder="username"
|
||||
className="bg-panel border border-edge rounded px-2 py-1.5 text-sm text-gray-200" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-gray-500">테넌트</label>
|
||||
<input value={tenantCode} onChange={e => setTenantCode(e.target.value)} placeholder="EMART"
|
||||
className="bg-panel border border-edge rounded px-2 py-1.5 text-sm text-gray-200" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-gray-500">시작 일시</label>
|
||||
<input type="datetime-local" value={from} onChange={e => setFrom(e.target.value)}
|
||||
className="bg-panel border border-edge rounded px-2 py-1.5 text-sm text-gray-200" />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs text-gray-500">종료 일시</label>
|
||||
<input type="datetime-local" value={to} onChange={e => setTo(e.target.value)}
|
||||
className="bg-panel border border-edge rounded px-2 py-1.5 text-sm text-gray-200" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={search}
|
||||
className="flex-1 px-3 py-1.5 rounded bg-brand/80 text-white text-sm hover:bg-brand">조회</button>
|
||||
<button onClick={reset}
|
||||
className="px-3 py-1.5 rounded bg-edge/60 text-gray-300 text-sm hover:bg-edge">초기화</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="text-gray-400 p-8">로딩 중...</div>
|
||||
) : (
|
||||
<div className="bg-card border border-edge rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-panel border-b border-edge">
|
||||
<tr>
|
||||
{['일시','수행자','테넌트','액션','리소스','대상','상세','IP','결과'].map(h => (
|
||||
<th key={h} className="px-4 py-3 text-left text-xs text-gray-400 font-medium">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(logs as any[]).map((l: any) => (
|
||||
<tr key={l.id} className="border-b border-edge hover:bg-edge/30">
|
||||
<td className="px-4 py-3 text-gray-500 text-xs whitespace-nowrap">
|
||||
{l.createdAt ? new Date(l.createdAt).toLocaleString('ko-KR') : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-300">{l.username || '-'}</td>
|
||||
<td className="px-4 py-3 text-brand text-xs">{l.tenantCode || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${actionColor[l.action] || 'bg-gray-500/20 text-gray-400'}`}>
|
||||
{l.action}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-400 text-xs">{l.resource || '-'}</td>
|
||||
<td className="px-4 py-3 text-gray-400 text-xs">{l.resourceId || '-'}</td>
|
||||
<td className="px-4 py-3 text-gray-400 text-xs max-w-xs truncate" title={l.detail}>{l.detail || '-'}</td>
|
||||
<td className="px-4 py-3 text-gray-500 text-xs">{l.ipAddress || '-'}</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${l.success === false ? 'bg-red-500/20 text-red-400' : 'bg-green-500/20 text-green-400'}`}>
|
||||
{l.success === false ? '실패' : '성공'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(logs as any[]).length === 0 && (
|
||||
<tr><td colSpan={9} className="px-4 py-8 text-center text-gray-500">감사 로그가 없습니다</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -1,14 +1,22 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Trash2, Star } from 'lucide-react'
|
||||
import { getFirmware, deleteFirmware } from '../api/client'
|
||||
import { Plus, Trash2, Star, X } from 'lucide-react'
|
||||
import { getFirmware, createFirmware, deleteFirmware } from '../api/client'
|
||||
|
||||
export default function FirmwareList() {
|
||||
const qc = useQueryClient()
|
||||
const [modal, setModal] = useState<any>(null)
|
||||
|
||||
const { data: fws = [], isLoading } = useQuery({
|
||||
queryKey: ['firmware'],
|
||||
queryFn: () => getFirmware(),
|
||||
})
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (d: any) => createFirmware(d),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['firmware'] }); setModal(null) },
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: deleteFirmware,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['firmware'] }),
|
||||
@ -18,7 +26,13 @@ export default function FirmwareList() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-white">펌웨어 관리</h1>
|
||||
<button onClick={() => setModal({})}
|
||||
className="flex items-center gap-1.5 bg-brand hover:bg-brand2 text-white px-3 py-1.5 rounded text-sm">
|
||||
<Plus size={14} /> 펌웨어 등록
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-card border border-edge rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-panel border-b border-edge">
|
||||
@ -59,6 +73,91 @@ export default function FirmwareList() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{modal && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div className="bg-card border border-edge rounded-lg p-6 w-full max-w-md">
|
||||
<div className="flex justify-between mb-4">
|
||||
<h2 className="font-semibold">펌웨어 등록</h2>
|
||||
<button onClick={() => setModal(null)}><X size={16} /></button>
|
||||
</div>
|
||||
<FirmwareForm onSave={(d: any) => saveMut.mutate(d)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FirmwareForm({ onSave }: { onSave: (d: any) => void }) {
|
||||
const [f, setF] = useState({
|
||||
tenantCode: 'EMART',
|
||||
firmwareVersion: '',
|
||||
deviceType: 'ESL_DEVICE',
|
||||
fileName: '',
|
||||
filePath: '',
|
||||
fileSize: '',
|
||||
checksum: '',
|
||||
description: '',
|
||||
latest: true,
|
||||
})
|
||||
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) =>
|
||||
setF(p => ({ ...p, [k]: e.target.value }))
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSave({
|
||||
...f,
|
||||
fileSize: f.fileSize === '' ? null : Number(f.fileSize),
|
||||
latest: f.latest === true || (f as any).latest === 'true',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">테넌트</label>
|
||||
<select value={f.tenantCode} onChange={set('tenantCode')}
|
||||
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white">
|
||||
{['TENANT_A','TENANT_B','EMART','ZIOINFO'].map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">장치 유형</label>
|
||||
<select value={f.deviceType} onChange={set('deviceType')}
|
||||
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white">
|
||||
{['GATEWAY','HUB','ESL_DEVICE'].map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{[
|
||||
{ label: '펌웨어 버전', key: 'firmwareVersion', ph: '예: v1.2.3', required: true },
|
||||
{ label: '파일명', key: 'fileName', ph: 'firmware_v1.2.3.bin' },
|
||||
{ label: '파일 경로', key: 'filePath', ph: '/opt/esn/firmware/...' },
|
||||
{ label: '파일 크기(byte)', key: 'fileSize', type: 'number' },
|
||||
{ label: '체크섬(SHA256)', key: 'checksum' },
|
||||
].map(({ label, key, ph, type, required }) => (
|
||||
<div key={key}>
|
||||
<label className="block text-xs text-gray-400 mb-1">{label}</label>
|
||||
<input type={type || 'text'} value={(f as any)[key]} onChange={set(key)} placeholder={ph} required={required}
|
||||
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white" />
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">설명</label>
|
||||
<textarea value={f.description} onChange={set('description')} rows={2}
|
||||
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">최신 버전 지정</label>
|
||||
<select value={String(f.latest)} onChange={set('latest')}
|
||||
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white">
|
||||
<option value="true">최신으로 지정</option>
|
||||
<option value="false">일반</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" className="w-full bg-brand hover:bg-brand2 text-white py-2 rounded text-sm">
|
||||
등록
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,17 +1,23 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { getProducts, deleteProduct } from '../api/client'
|
||||
import { Plus, Edit, Trash2, X } from 'lucide-react'
|
||||
import { getProducts, createProduct, updateProduct, deleteProduct } from '../api/client'
|
||||
|
||||
export default function ProductList() {
|
||||
const qc = useQueryClient()
|
||||
const [keyword, setKeyword] = useState('')
|
||||
const [modal, setModal] = useState<any>(null)
|
||||
|
||||
const { data: products = [], isLoading } = useQuery({
|
||||
queryKey: ['products', keyword],
|
||||
queryFn: () => getProducts({ keyword: keyword || undefined }),
|
||||
})
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (d: any) => d.id ? updateProduct(d.id, d) : createProduct(d),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['products'] }); setModal(null) },
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: deleteProduct,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['products'] }),
|
||||
@ -23,9 +29,15 @@ export default function ProductList() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-white">상품/가격 관리</h1>
|
||||
<div className="flex gap-2">
|
||||
<input value={keyword} onChange={e => setKeyword(e.target.value)}
|
||||
placeholder="상품명·코드 검색"
|
||||
className="bg-panel border border-edge rounded px-3 py-1.5 text-sm text-gray-300 w-48" />
|
||||
<button onClick={() => setModal({})}
|
||||
className="flex items-center gap-1.5 bg-brand hover:bg-brand2 text-white px-3 py-1.5 rounded text-sm">
|
||||
<Plus size={14} /> 상품 추가
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-card border border-edge rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
@ -53,8 +65,11 @@ export default function ProductList() {
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-3">
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setModal(p)} className="text-brand hover:text-blue-300"><Edit size={14} /></button>
|
||||
<button onClick={() => { if(confirm('삭제?')) deleteMut.mutate(p.id) }}
|
||||
className="text-red-400 hover:text-red-300"><Trash2 size={14} /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@ -64,6 +79,90 @@ export default function ProductList() {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{modal && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div className="bg-card border border-edge rounded-lg p-6 w-full max-w-md">
|
||||
<div className="flex justify-between mb-4">
|
||||
<h2 className="font-semibold">{modal.id ? '상품 수정 / 가격 변경' : '상품 추가'}</h2>
|
||||
<button onClick={() => setModal(null)}><X size={16} /></button>
|
||||
</div>
|
||||
<ProductForm initial={modal} onSave={(d: any) => saveMut.mutate(d)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ProductForm({ initial, onSave }: { initial: any; onSave: (d: any) => void }) {
|
||||
const [f, setF] = useState({
|
||||
tenantCode: initial.tenantCode || 'EMART',
|
||||
storeId: initial.storeId ?? '',
|
||||
productCode: initial.productCode || '',
|
||||
productName: initial.productName || '',
|
||||
category: initial.category || '',
|
||||
price: initial.price ?? '',
|
||||
salePrice: initial.salePrice ?? '',
|
||||
currency: initial.currency || 'KRW',
|
||||
active: initial.active !== false,
|
||||
id: initial.id,
|
||||
})
|
||||
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
|
||||
setF(p => ({ ...p, [k]: e.target.value }))
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSave({
|
||||
...f,
|
||||
storeId: f.storeId === '' ? null : Number(f.storeId),
|
||||
price: f.price === '' ? null : Number(f.price),
|
||||
salePrice: f.salePrice === '' ? null : Number(f.salePrice),
|
||||
active: f.active === true || (f as any).active === 'true',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">테넌트</label>
|
||||
<select value={f.tenantCode} onChange={set('tenantCode')}
|
||||
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white">
|
||||
{['TENANT_A','TENANT_B','EMART','ZIOINFO'].map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{[
|
||||
{ label: '매장 ID', key: 'storeId', type: 'number', ph: '예: 1' },
|
||||
{ label: '상품코드', key: 'productCode' },
|
||||
{ label: '상품명', key: 'productName' },
|
||||
{ label: '카테고리', key: 'category' },
|
||||
{ label: '정가', key: 'price', type: 'number' },
|
||||
{ label: '판매가', key: 'salePrice', type: 'number' },
|
||||
].map(({ label, key, type, ph }) => (
|
||||
<div key={key}>
|
||||
<label className="block text-xs text-gray-400 mb-1">{label}</label>
|
||||
<input type={type || 'text'} value={(f as any)[key]} onChange={set(key)} placeholder={ph}
|
||||
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white" />
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">화폐</label>
|
||||
<select value={f.currency} onChange={set('currency')}
|
||||
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white">
|
||||
{['KRW','USD','EUR'].map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">상태</label>
|
||||
<select value={String(f.active)} onChange={set('active')}
|
||||
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white">
|
||||
<option value="true">활성</option>
|
||||
<option value="false">비활성</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" className="w-full bg-brand hover:bg-brand2 text-white py-2 rounded text-sm">
|
||||
저장
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
100
frontend/src/pages/SystemSettings.tsx
Normal file
100
frontend/src/pages/SystemSettings.tsx
Normal file
@ -0,0 +1,100 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { getSettings, updateSetting } from '../api/client'
|
||||
|
||||
const categoryColor: Record<string, string> = {
|
||||
GENERAL: 'bg-gray-500/20 text-gray-300',
|
||||
ESL: 'bg-purple-500/20 text-purple-300',
|
||||
ALARM: 'bg-amber-500/20 text-amber-300',
|
||||
AI: 'bg-cyan-500/20 text-cyan-300',
|
||||
}
|
||||
|
||||
export default function SystemSettings() {
|
||||
const qc = useQueryClient()
|
||||
const [editing, setEditing] = useState<Record<string, string>>({})
|
||||
|
||||
const { data: settings = [], isLoading } = useQuery({
|
||||
queryKey: ['settings'],
|
||||
queryFn: () => getSettings(),
|
||||
})
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: ({ key, value }: { key: string; value: string }) => updateSetting(key, value),
|
||||
onSuccess: (_d, vars) => {
|
||||
qc.invalidateQueries({ queryKey: ['settings'] })
|
||||
setEditing(e => { const n = { ...e }; delete n[vars.key]; return n })
|
||||
},
|
||||
})
|
||||
|
||||
if (isLoading) return <div className="text-gray-400 p-8">로딩 중...</div>
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h1 className="text-xl font-semibold text-white">시스템 설정</h1>
|
||||
<p className="text-xs text-gray-500">
|
||||
운영 설정 변경은 감사 로그에 기록됩니다. (변경은 ADMIN 권한 필요)
|
||||
</p>
|
||||
|
||||
<div className="bg-card border border-edge rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-panel border-b border-edge">
|
||||
<tr>
|
||||
{['분류','설정 키','값','설명','수정 일시',''].map(h => (
|
||||
<th key={h} className="px-4 py-3 text-left text-xs text-gray-400 font-medium">{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(settings as any[]).map((s: any) => {
|
||||
const isEdit = s.settingKey in editing
|
||||
return (
|
||||
<tr key={s.settingKey} className="border-b border-edge hover:bg-edge/30">
|
||||
<td className="px-4 py-3">
|
||||
<span className={`px-2 py-0.5 rounded text-xs ${categoryColor[s.category] || 'bg-gray-500/20 text-gray-400'}`}>
|
||||
{s.category}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-200 font-mono text-xs">{s.settingKey}</td>
|
||||
<td className="px-4 py-3">
|
||||
{isEdit ? (
|
||||
<input
|
||||
value={editing[s.settingKey]}
|
||||
onChange={e => setEditing(ed => ({ ...ed, [s.settingKey]: e.target.value }))}
|
||||
className="bg-panel border border-brand/60 rounded px-2 py-1 text-sm text-gray-100 w-48" />
|
||||
) : (
|
||||
<span className="text-gray-300">{s.settingValue}</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-gray-500 text-xs max-w-xs">{s.description || '-'}</td>
|
||||
<td className="px-4 py-3 text-gray-500 text-xs">
|
||||
{s.updatedAt ? new Date(s.updatedAt).toLocaleString('ko-KR') : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right">
|
||||
{isEdit ? (
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => save.mutate({ key: s.settingKey, value: editing[s.settingKey] })}
|
||||
disabled={save.isPending}
|
||||
className="text-xs px-3 py-1 rounded bg-brand/80 text-white hover:bg-brand">저장</button>
|
||||
<button
|
||||
onClick={() => setEditing(e => { const n = { ...e }; delete n[s.settingKey]; return n })}
|
||||
className="text-xs px-3 py-1 rounded bg-edge/60 text-gray-300 hover:bg-edge">취소</button>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setEditing(e => ({ ...e, [s.settingKey]: s.settingValue ?? '' }))}
|
||||
className="text-xs px-3 py-1 rounded bg-edge/60 text-gray-300 hover:bg-edge">수정</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
{(settings as any[]).length === 0 && (
|
||||
<tr><td colSpan={6} className="px-4 py-8 text-center text-gray-500">설정이 없습니다</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -38,7 +38,7 @@ export default function TagBindingList() {
|
||||
|
||||
const { data: bindings = [], isLoading } = useQuery<TagBinding[]>({
|
||||
queryKey: ['tag-binding', tenantCode, storeId, keyword],
|
||||
queryFn: () => api.get('/tag-binding', { params: {
|
||||
queryFn: () => api.get('/api/tag-binding', { params: {
|
||||
tenantCode: tenantCode || undefined,
|
||||
storeId: storeId || undefined,
|
||||
keyword: keyword || undefined,
|
||||
@ -48,7 +48,7 @@ export default function TagBindingList() {
|
||||
})
|
||||
|
||||
const bindMut = useMutation({
|
||||
mutationFn: (data: BindForm) => api.post('/tag-binding', {
|
||||
mutationFn: (data: BindForm) => api.post('/api/tag-binding', {
|
||||
...data,
|
||||
storeId: Number(data.storeId),
|
||||
}),
|
||||
@ -56,7 +56,7 @@ export default function TagBindingList() {
|
||||
})
|
||||
|
||||
const unbindMut = useMutation({
|
||||
mutationFn: (id: number) => api.put(`/tag-binding/${id}/unbind`),
|
||||
mutationFn: (id: number) => api.put(`/api/tag-binding/${id}/unbind`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['tag-binding'] }),
|
||||
})
|
||||
|
||||
|
||||
@ -1,14 +1,22 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Plus, Trash2 } from 'lucide-react'
|
||||
import { getTemplates, createTemplate, deleteTemplate } from '../api/client'
|
||||
import { Plus, Edit, Trash2, X } from 'lucide-react'
|
||||
import { getTemplates, createTemplate, updateTemplate, deleteTemplate } from '../api/client'
|
||||
|
||||
export default function TemplateList() {
|
||||
const qc = useQueryClient()
|
||||
const [modal, setModal] = useState<any>(null)
|
||||
|
||||
const { data: templates = [], isLoading } = useQuery({
|
||||
queryKey: ['templates'],
|
||||
queryFn: () => getTemplates(),
|
||||
})
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (d: any) => d.id ? updateTemplate(d.id, d) : createTemplate(d),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['templates'] }); setModal(null) },
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: deleteTemplate,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['templates'] }),
|
||||
@ -20,6 +28,10 @@ export default function TemplateList() {
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-white">ESL 템플릿</h1>
|
||||
<button onClick={() => setModal({})}
|
||||
className="flex items-center gap-1.5 bg-brand hover:bg-brand2 text-white px-3 py-1.5 rounded text-sm">
|
||||
<Plus size={14} /> 템플릿 추가
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{(templates as any[]).map((t: any) => (
|
||||
@ -29,9 +41,12 @@ export default function TemplateList() {
|
||||
<p className="text-sm font-medium text-white">{t.templateName}</p>
|
||||
<p className="text-xs text-brand mt-0.5">{t.tenantCode} · {t.templateType}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setModal(t)} className="text-brand hover:text-blue-300"><Edit size={14} /></button>
|
||||
<button onClick={() => { if(confirm('삭제?')) deleteMut.mutate(t.id) }}
|
||||
className="text-red-400 hover:text-red-300"><Trash2 size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-panel border border-edge rounded p-3 flex items-center justify-center"
|
||||
style={{ height: Math.min((t.height || 76) / 2 + 40, 120) }}>
|
||||
<div className="text-center">
|
||||
@ -46,6 +61,102 @@ export default function TemplateList() {
|
||||
<div className="col-span-3 text-center text-gray-500 py-8">템플릿이 없습니다</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{modal && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div className="bg-card border border-edge rounded-lg p-6 w-full max-w-md">
|
||||
<div className="flex justify-between mb-4">
|
||||
<h2 className="font-semibold">{modal.id ? '템플릿 수정' : '템플릿 추가'}</h2>
|
||||
<button onClick={() => setModal(null)}><X size={16} /></button>
|
||||
</div>
|
||||
<TemplateForm initial={modal} onSave={(d: any) => saveMut.mutate(d)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TemplateForm({ initial, onSave }: { initial: any; onSave: (d: any) => void }) {
|
||||
const isEdit = !!initial.id
|
||||
const [f, setF] = useState({
|
||||
tenantCode: initial.tenantCode || 'EMART',
|
||||
templateCode: initial.templateCode || '',
|
||||
templateName: initial.templateName || '',
|
||||
templateType: initial.templateType || 'PRICE',
|
||||
width: initial.width ?? 250,
|
||||
height: initial.height ?? 122,
|
||||
description: initial.description || '',
|
||||
layoutJson: initial.layoutJson || '',
|
||||
active: initial.active !== false,
|
||||
id: initial.id,
|
||||
})
|
||||
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) =>
|
||||
setF(p => ({ ...p, [k]: e.target.value }))
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSave({
|
||||
...f,
|
||||
width: f.width === ('' as any) ? null : Number(f.width),
|
||||
height: f.height === ('' as any) ? null : Number(f.height),
|
||||
active: f.active === true || (f as any).active === 'true',
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">테넌트</label>
|
||||
<select value={f.tenantCode} onChange={set('tenantCode')}
|
||||
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white">
|
||||
{['TENANT_A','TENANT_B','EMART','ZIOINFO'].map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">템플릿 코드</label>
|
||||
<input value={f.templateCode} onChange={set('templateCode')} disabled={isEdit} required
|
||||
placeholder="예: EM-PRICE-STD"
|
||||
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white disabled:opacity-50" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">템플릿명</label>
|
||||
<input value={f.templateName} onChange={set('templateName')} required
|
||||
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">유형</label>
|
||||
<select value={f.templateType} onChange={set('templateType')}
|
||||
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white">
|
||||
{['PRICE','INFO','PROMO','CUSTOM'].map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{[
|
||||
{ label: '너비(px)', key: 'width' },
|
||||
{ label: '높이(px)', key: 'height' },
|
||||
].map(({ label, key }) => (
|
||||
<div key={key}>
|
||||
<label className="block text-xs text-gray-400 mb-1">{label}</label>
|
||||
<input type="number" value={(f as any)[key]} onChange={set(key)}
|
||||
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">설명</label>
|
||||
<input value={f.description} onChange={set('description')}
|
||||
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">레이아웃 JSON (선택)</label>
|
||||
<textarea value={f.layoutJson} onChange={set('layoutJson')} rows={2}
|
||||
placeholder='{"fields":[...]}'
|
||||
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white font-mono" />
|
||||
</div>
|
||||
<button type="submit" className="w-full bg-brand hover:bg-brand2 text-white py-2 rounded text-sm">
|
||||
저장
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,14 +1,22 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { getTenants, deleteTenant } from '../api/client'
|
||||
import { Building2, Trash2 } from 'lucide-react'
|
||||
import { getTenants, createTenant, updateTenant, deleteTenant } from '../api/client'
|
||||
import { Building2, Plus, Edit, Trash2, X } from 'lucide-react'
|
||||
|
||||
export default function TenantAdmin() {
|
||||
const qc = useQueryClient()
|
||||
const [modal, setModal] = useState<any>(null)
|
||||
|
||||
const { data: tenants = [], isLoading } = useQuery({
|
||||
queryKey: ['tenants'],
|
||||
queryFn: () => getTenants(),
|
||||
})
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (d: any) => d.id ? updateTenant(d.id, d) : createTenant(d),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['tenants'] }); setModal(null) },
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: deleteTenant,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenants'] }),
|
||||
@ -25,7 +33,13 @@ export default function TenantAdmin() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-white">테넌트 관리</h1>
|
||||
<button onClick={() => setModal({})}
|
||||
className="flex items-center gap-1.5 bg-brand hover:bg-brand2 text-white px-3 py-1.5 rounded text-sm">
|
||||
<Plus size={14} /> 테넌트 추가
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{(tenants as any[]).map((t: any) => (
|
||||
<div key={t.id} className="bg-card border border-edge rounded-lg p-4">
|
||||
@ -34,9 +48,12 @@ export default function TenantAdmin() {
|
||||
<Building2 size={18} className="text-brand" />
|
||||
<span className="font-bold text-brand">{t.tenantCode}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setModal(t)} className="text-brand hover:text-blue-300"><Edit size={14} /></button>
|
||||
<button onClick={() => { if(confirm('삭제?')) deleteMut.mutate(t.id) }}
|
||||
className="text-red-400 hover:text-red-300"><Trash2 size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-white">{t.tenantName}</p>
|
||||
<p className="text-xs text-gray-400 mt-1">{t.description}</p>
|
||||
<div className="mt-3">
|
||||
@ -46,7 +63,72 @@ export default function TenantAdmin() {
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{(tenants as any[]).length === 0 && (
|
||||
<div className="col-span-full text-center text-gray-500 py-12">테넌트가 없습니다</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{modal && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div className="bg-card border border-edge rounded-lg p-6 w-full max-w-md">
|
||||
<div className="flex justify-between mb-4">
|
||||
<h2 className="font-semibold">{modal.id ? '테넌트 수정' : '테넌트 추가'}</h2>
|
||||
<button onClick={() => setModal(null)}><X size={16} /></button>
|
||||
</div>
|
||||
<TenantForm initial={modal} onSave={(d: any) => saveMut.mutate(d)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function TenantForm({ initial, onSave }: { initial: any; onSave: (d: any) => void }) {
|
||||
const isEdit = !!initial.id
|
||||
const [f, setF] = useState({
|
||||
tenantCode: initial.tenantCode || '',
|
||||
tenantName: initial.tenantName || '',
|
||||
description: initial.description || '',
|
||||
active: initial.active !== false,
|
||||
id: initial.id,
|
||||
})
|
||||
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) =>
|
||||
setF(p => ({ ...p, [k]: e.target.value }))
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
onSave({ ...f, active: f.active === true || (f as any).active === 'true' })
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">테넌트 코드</label>
|
||||
<input value={f.tenantCode} onChange={set('tenantCode')} disabled={isEdit} required
|
||||
placeholder="예: EMART"
|
||||
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white disabled:opacity-50" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">테넌트명</label>
|
||||
<input value={f.tenantName} onChange={set('tenantName')} required
|
||||
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">설명</label>
|
||||
<textarea value={f.description} onChange={set('description')} rows={2}
|
||||
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">상태</label>
|
||||
<select value={String(f.active)} onChange={set('active')}
|
||||
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white">
|
||||
<option value="true">운영중</option>
|
||||
<option value="false">비활성</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" className="w-full bg-brand hover:bg-brand2 text-white py-2 rounded text-sm">
|
||||
저장
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
@ -27,7 +27,7 @@ export default function UpdateQueueList() {
|
||||
|
||||
const { data: items = [], isLoading, refetch } = useQuery<QueueItem[]>({
|
||||
queryKey: ['update-queue', tenantCode, status],
|
||||
queryFn: () => api.get('/update-queue', { params: {
|
||||
queryFn: () => api.get('/api/update-queue', { params: {
|
||||
tenantCode: tenantCode || undefined,
|
||||
status: status || undefined,
|
||||
}}).then(r => r.data.data),
|
||||
@ -35,7 +35,7 @@ export default function UpdateQueueList() {
|
||||
})
|
||||
|
||||
const cancelMut = useMutation({
|
||||
mutationFn: (id: number) => api.put(`/update-queue/${id}/cancel`),
|
||||
mutationFn: (id: number) => api.put(`/api/update-queue/${id}/cancel`),
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['update-queue'] }),
|
||||
})
|
||||
|
||||
|
||||
@ -1,14 +1,22 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Trash2 } from 'lucide-react'
|
||||
import { getUsers, deleteUser } from '../api/client'
|
||||
import { Plus, Edit, Trash2, X } from 'lucide-react'
|
||||
import { getUsers, createUser, updateUser, deleteUser } from '../api/client'
|
||||
|
||||
export default function UserList() {
|
||||
const qc = useQueryClient()
|
||||
const [modal, setModal] = useState<any>(null)
|
||||
|
||||
const { data: users = [], isLoading } = useQuery({
|
||||
queryKey: ['users'],
|
||||
queryFn: () => getUsers(),
|
||||
})
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (d: any) => d.id ? updateUser(d.id, d) : createUser(d),
|
||||
onSuccess: () => { qc.invalidateQueries({ queryKey: ['users'] }); setModal(null) },
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: deleteUser,
|
||||
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
|
||||
@ -18,7 +26,13 @@ export default function UserList() {
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-xl font-semibold text-white">사용자 관리</h1>
|
||||
<button onClick={() => setModal({})}
|
||||
className="flex items-center gap-1.5 bg-brand hover:bg-brand2 text-white px-3 py-1.5 rounded text-sm">
|
||||
<Plus size={14} /> 사용자 추가
|
||||
</button>
|
||||
</div>
|
||||
<div className="bg-card border border-edge rounded-lg overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-panel border-b border-edge">
|
||||
@ -51,14 +65,117 @@ export default function UserList() {
|
||||
{u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString('ko-KR') : '-'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => setModal(u)} className="text-brand hover:text-blue-300"><Edit size={14} /></button>
|
||||
<button onClick={() => { if(confirm('삭제?')) deleteMut.mutate(u.id) }}
|
||||
className="text-red-400 hover:text-red-300"><Trash2 size={14} /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{(users as any[]).length === 0 && (
|
||||
<tr><td colSpan={8} className="px-4 py-8 text-center text-gray-500">사용자가 없습니다</td></tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{modal && (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div className="bg-card border border-edge rounded-lg p-6 w-full max-w-md">
|
||||
<div className="flex justify-between mb-4">
|
||||
<h2 className="font-semibold">{modal.id ? '사용자 수정' : '사용자 추가'}</h2>
|
||||
<button onClick={() => setModal(null)}><X size={16} /></button>
|
||||
</div>
|
||||
<UserForm initial={modal} onSave={(d: any) => saveMut.mutate(d)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function UserForm({ initial, onSave }: { initial: any; onSave: (d: any) => void }) {
|
||||
const isEdit = !!initial.id
|
||||
const [f, setF] = useState({
|
||||
username: initial.username || '',
|
||||
role: initial.role || 'USER',
|
||||
tenantCode: initial.tenantCode || '',
|
||||
email: initial.email || '',
|
||||
phone: initial.phone || '',
|
||||
password: '',
|
||||
active: initial.active !== false,
|
||||
id: initial.id,
|
||||
})
|
||||
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
|
||||
setF(p => ({ ...p, [k]: e.target.value }))
|
||||
|
||||
const submit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const payload: any = {
|
||||
username: f.username,
|
||||
role: f.role,
|
||||
tenantCode: f.tenantCode || null,
|
||||
email: f.email || null,
|
||||
phone: f.phone || null,
|
||||
active: f.active === true || (f as any).active === 'true',
|
||||
id: f.id,
|
||||
}
|
||||
// 신규 생성 시에만 비밀번호 포함(미입력 시 백엔드 기본값 사용)
|
||||
if (!isEdit && f.password) payload.password = f.password
|
||||
onSave(payload)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={submit} className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">아이디</label>
|
||||
<input value={f.username} onChange={set('username')} disabled={isEdit} required
|
||||
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white disabled:opacity-50" />
|
||||
</div>
|
||||
{!isEdit && (
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">초기 비밀번호 (미입력 시 changeme123)</label>
|
||||
<input type="password" value={f.password} onChange={set('password')} autoComplete="new-password"
|
||||
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">역할</label>
|
||||
<select value={f.role} onChange={set('role')}
|
||||
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white">
|
||||
{['ADMIN','MANAGER','USER'].map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">테넌트 (SUPER는 비움)</label>
|
||||
<select value={f.tenantCode} onChange={set('tenantCode')}
|
||||
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white">
|
||||
<option value="">(SUPER / 전체)</option>
|
||||
{['TENANT_A','TENANT_B','EMART','ZIOINFO'].map(o => <option key={o} value={o}>{o}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
{[
|
||||
{ label: '이메일', key: 'email', type: 'email' },
|
||||
{ label: '전화', key: 'phone' },
|
||||
].map(({ label, key, type }) => (
|
||||
<div key={key}>
|
||||
<label className="block text-xs text-gray-400 mb-1">{label}</label>
|
||||
<input type={type || 'text'} value={(f as any)[key]} onChange={set(key)}
|
||||
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white" />
|
||||
</div>
|
||||
))}
|
||||
<div>
|
||||
<label className="block text-xs text-gray-400 mb-1">상태</label>
|
||||
<select value={String(f.active)} onChange={set('active')}
|
||||
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white">
|
||||
<option value="true">활성</option>
|
||||
<option value="false">비활성</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" className="w-full bg-brand hover:bg-brand2 text-white py-2 rounded text-sm">
|
||||
저장
|
||||
</button>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user