feat: 솔루션별 AI 에이전트(최신 기법·중앙 guardia-rag) 하네스 추가

This commit is contained in:
GUARDiA 2026-06-21 02:42:12 +09:00
parent f60cf93254
commit 95f8106f82
39 changed files with 2246 additions and 36 deletions

View 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` 준수.

View 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 번들) 유지.

View 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` 준수.

View 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 검증.

View File

@ -119,3 +119,11 @@ workspace/guardia-esn/
- 배포 작업 - 배포 작업
- 레거시 소스 분석·통합 - 레거시 소스 분석·통합
- 알람·POS·HCore 관련 작업 - 알람·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)

View File

@ -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")));
}
}

View File

@ -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();
}
}

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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);
}

View File

@ -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);
}

View File

@ -44,6 +44,10 @@ public class SecurityConfig {
.requestMatchers("/api/tenants/**").hasRole("ADMIN") .requestMatchers("/api/tenants/**").hasRole("ADMIN")
.requestMatchers("/api/users/**").hasAnyRole("ADMIN", "MANAGER") .requestMatchers("/api/users/**").hasAnyRole("ADMIN", "MANAGER")
.requestMatchers("/api/firmware/**").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.DELETE, "/api/**").hasAnyRole("ADMIN", "MANAGER")
.requestMatchers(HttpMethod.POST, "/api/**").hasAnyRole("ADMIN", "MANAGER", "USER") .requestMatchers(HttpMethod.POST, "/api/**").hasAnyRole("ADMIN", "MANAGER", "USER")
.requestMatchers(HttpMethod.PUT, "/api/**").hasAnyRole("ADMIN", "MANAGER", "USER") .requestMatchers(HttpMethod.PUT, "/api/**").hasAnyRole("ADMIN", "MANAGER", "USER")

View File

@ -1,5 +1,6 @@
package com.zioinfo.esn.controller; package com.zioinfo.esn.controller;
import com.zioinfo.esn.admin.AuditService;
import com.zioinfo.esn.common.ApiResponse; import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.domain.FirmwareVo; import com.zioinfo.esn.domain.FirmwareVo;
import com.zioinfo.esn.service.FirmwareService; import com.zioinfo.esn.service.FirmwareService;
@ -12,6 +13,7 @@ import java.util.List;
@RequiredArgsConstructor @RequiredArgsConstructor
public class FirmwareController { public class FirmwareController {
private final FirmwareService service; private final FirmwareService service;
private final AuditService audit;
@GetMapping @GetMapping
public ApiResponse<List<FirmwareVo>> list( public ApiResponse<List<FirmwareVo>> list(
@ -27,17 +29,27 @@ public class FirmwareController {
@PostMapping @PostMapping
public ApiResponse<FirmwareVo> create(@RequestBody FirmwareVo v) { 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}") @PutMapping("/{id}")
public ApiResponse<FirmwareVo> update(@PathVariable Long id, @RequestBody FirmwareVo v) { 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}") @DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) { public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id); service.delete(id);
audit.log(null, "FIRMWARE_DELETE", "FIRMWARE", String.valueOf(id), "펌웨어 삭제", true);
return ApiResponse.ok("펌웨어 삭제 완료", null); return ApiResponse.ok("펌웨어 삭제 완료", null);
} }
} }

View File

@ -1,5 +1,6 @@
package com.zioinfo.esn.controller; package com.zioinfo.esn.controller;
import com.zioinfo.esn.admin.AuditService;
import com.zioinfo.esn.common.ApiResponse; import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.domain.PosCvtVo; import com.zioinfo.esn.domain.PosCvtVo;
import com.zioinfo.esn.service.PosCvtService; import com.zioinfo.esn.service.PosCvtService;
@ -13,6 +14,7 @@ import java.util.Map;
@RequiredArgsConstructor @RequiredArgsConstructor
public class PosCvtController { public class PosCvtController {
private final PosCvtService service; private final PosCvtService service;
private final AuditService audit;
@GetMapping @GetMapping
public ApiResponse<List<PosCvtVo>> list( public ApiResponse<List<PosCvtVo>> list(
@ -30,7 +32,13 @@ public class PosCvtController {
@PutMapping("/{id}/process") @PutMapping("/{id}/process")
public ApiResponse<Integer> process(@PathVariable Long id) { public ApiResponse<Integer> process(@PathVariable Long id) {
PosCvtVo pos = service.get(id);
int queued = service.process(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); return ApiResponse.ok("처리 완료 (태그 큐 " + queued + "건)", queued);
} }

View File

@ -1,5 +1,6 @@
package com.zioinfo.esn.controller; package com.zioinfo.esn.controller;
import com.zioinfo.esn.admin.AuditService;
import com.zioinfo.esn.common.ApiResponse; import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.domain.TenantVo; import com.zioinfo.esn.domain.TenantVo;
import com.zioinfo.esn.service.TenantService; import com.zioinfo.esn.service.TenantService;
@ -12,6 +13,7 @@ import java.util.List;
@RequiredArgsConstructor @RequiredArgsConstructor
public class TenantController { public class TenantController {
private final TenantService service; private final TenantService service;
private final AuditService audit;
@GetMapping @GetMapping
public ApiResponse<List<TenantVo>> list() { public ApiResponse<List<TenantVo>> list() {
@ -25,17 +27,24 @@ public class TenantController {
@PostMapping @PostMapping
public ApiResponse<TenantVo> create(@RequestBody TenantVo v) { 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}") @PutMapping("/{id}")
public ApiResponse<TenantVo> update(@PathVariable Long id, @RequestBody TenantVo v) { 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}") @DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) { public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id); service.delete(id);
audit.log(null, "TENANT_DELETE", "TENANT", String.valueOf(id), "테넌트 삭제", true);
return ApiResponse.ok("테넌트 삭제 완료", null); return ApiResponse.ok("테넌트 삭제 완료", null);
} }
} }

View File

@ -1,5 +1,6 @@
package com.zioinfo.esn.controller; package com.zioinfo.esn.controller;
import com.zioinfo.esn.admin.AuditService;
import com.zioinfo.esn.auth.EsnUser; import com.zioinfo.esn.auth.EsnUser;
import com.zioinfo.esn.common.ApiResponse; import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.service.UserService; import com.zioinfo.esn.service.UserService;
@ -13,6 +14,7 @@ import java.util.Map;
@RequiredArgsConstructor @RequiredArgsConstructor
public class UserController { public class UserController {
private final UserService service; private final UserService service;
private final AuditService audit;
@GetMapping @GetMapping
public ApiResponse<List<EsnUser>> list( public ApiResponse<List<EsnUser>> list(
@ -36,23 +38,42 @@ public class UserController {
user.setPhone((String) body.get("phone")); user.setPhone((String) body.get("phone"));
user.setActive(true); user.setActive(true);
String rawPassword = (String) body.getOrDefault("password", "changeme123"); 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}") @PutMapping("/{id}")
public ApiResponse<EsnUser> update(@PathVariable Long id, @RequestBody EsnUser v) { 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") @PutMapping("/{id}/password")
public ApiResponse<Void> changePassword(@PathVariable Long id, @RequestBody Map<String, String> body) { public ApiResponse<Void> changePassword(@PathVariable Long id, @RequestBody Map<String, String> body) {
service.changePassword(id, body.getOrDefault("password", "changeme123")); service.changePassword(id, body.getOrDefault("password", "changeme123"));
// 보안: 비밀번호 값은 기록하지 않는다(대상 id ).
audit.log(null, "USER_PASSWORD_RESET", "USER", String.valueOf(id), "비밀번호 초기화", true);
return ApiResponse.ok("비밀번호 변경 완료", null); 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}") @DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) { public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id); service.delete(id);
audit.log(null, "USER_DELETE", "USER", String.valueOf(id), "사용자 삭제", true);
return ApiResponse.ok("사용자 삭제 완료", null); return ApiResponse.ok("사용자 삭제 완료", null);
} }
} }

View File

@ -35,5 +35,13 @@ public class UserService {
mapper.updatePassword(id, passwordEncoder.encode(rawPassword)); 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); } public void delete(Long id) { mapper.delete(id); }
} }

View File

@ -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_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_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;

View 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 &gt;= #{from}</if>
<if test="to != null">AND created_at &lt;= #{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>

View 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>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

View File

@ -16,6 +16,12 @@ import TenantAdmin from './pages/TenantAdmin'
import AiAnalysis from './pages/AiAnalysis' import AiAnalysis from './pages/AiAnalysis'
import TagBindingList from './pages/TagBindingList' import TagBindingList from './pages/TagBindingList'
import UpdateQueueList from './pages/UpdateQueueList' 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() const qc = new QueryClient()
@ -39,7 +45,13 @@ export default function App() {
<Route path="/products" element={<ProductList />} /> <Route path="/products" element={<ProductList />} />
<Route path="/users" element={<UserList />} /> <Route path="/users" element={<UserList />} />
<Route path="/tenants" element={<TenantAdmin />} /> <Route path="/tenants" element={<TenantAdmin />} />
<Route path="/audit" element={<AuditLog />} />
<Route path="/settings" element={<SystemSettings />} />
<Route path="/ai" element={<AiAnalysis />} /> <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>
<Route path="*" element={<Navigate to="/dashboard" replace />} /> <Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes> </Routes>

View File

@ -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 updateProduct = (id: number, d: object) => unwrap(api.put(`/api/products/${id}`, d))
export const deleteProduct = (id: number) => api.delete(`/api/products/${id}`) 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 ─────────────────────────────────────────────────────────────────── // ── AI ───────────────────────────────────────────────────────────────────
export const analyzeAlarm = (d: object) => unwrap(api.post('/api/ai/analyze-alarm', d)) 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)) export const classifyPos = (d: object) => unwrap(api.post('/api/ai/classify-pos', d))

View File

@ -2,7 +2,8 @@ import { NavLink } from 'react-router-dom'
import { import {
LayoutDashboard, Store, FileText, RefreshCw, LayoutDashboard, Store, FileText, RefreshCw,
Bell, Cpu, ClipboardList, Zap, Users, Package, Building2, Brain, Bell, Cpu, ClipboardList, Zap, Users, Package, Building2, Brain,
Link2, ListOrdered Link2, ListOrdered, NotebookPen, CalendarDays, Mail, BarChart3,
ScrollText, Settings
} from 'lucide-react' } from 'lucide-react'
const nav = [ const nav = [
@ -19,7 +20,13 @@ const nav = [
{ to: '/products', icon: Package, label: '상품/가격' }, { to: '/products', icon: Package, label: '상품/가격' },
{ to: '/users', icon: Users, label: '사용자' }, { to: '/users', icon: Users, label: '사용자' },
{ to: '/tenants', icon: Building2, label: '테넌트 관리' }, { to: '/tenants', icon: Building2, label: '테넌트 관리' },
{ to: '/audit', icon: ScrollText, label: '감사 로그' },
{ to: '/settings', icon: Settings, label: '시스템 설정' },
{ to: '/ai', icon: Brain, label: 'AI 분석' }, { 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() { export default function Sidebar() {

View 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>
)
}

View File

@ -1,14 +1,22 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Trash2, Star } from 'lucide-react' import { Plus, Trash2, Star, X } from 'lucide-react'
import { getFirmware, deleteFirmware } from '../api/client' import { getFirmware, createFirmware, deleteFirmware } from '../api/client'
export default function FirmwareList() { export default function FirmwareList() {
const qc = useQueryClient() const qc = useQueryClient()
const [modal, setModal] = useState<any>(null)
const { data: fws = [], isLoading } = useQuery({ const { data: fws = [], isLoading } = useQuery({
queryKey: ['firmware'], queryKey: ['firmware'],
queryFn: () => getFirmware(), queryFn: () => getFirmware(),
}) })
const saveMut = useMutation({
mutationFn: (d: any) => createFirmware(d),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['firmware'] }); setModal(null) },
})
const deleteMut = useMutation({ const deleteMut = useMutation({
mutationFn: deleteFirmware, mutationFn: deleteFirmware,
onSuccess: () => qc.invalidateQueries({ queryKey: ['firmware'] }), onSuccess: () => qc.invalidateQueries({ queryKey: ['firmware'] }),
@ -18,7 +26,13 @@ export default function FirmwareList() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-white"> </h1> <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"> <div className="bg-card border border-edge rounded-lg overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-panel border-b border-edge"> <thead className="bg-panel border-b border-edge">
@ -59,6 +73,91 @@ export default function FirmwareList() {
</tbody> </tbody>
</table> </table>
</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"> </h2>
<button onClick={() => setModal(null)}><X size={16} /></button>
</div>
<FirmwareForm onSave={(d: any) => saveMut.mutate(d)} />
</div>
</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>
)
}

View File

@ -1,17 +1,23 @@
import { useState } from 'react' import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Trash2 } from 'lucide-react' import { Plus, Edit, Trash2, X } from 'lucide-react'
import { getProducts, deleteProduct } from '../api/client' import { getProducts, createProduct, updateProduct, deleteProduct } from '../api/client'
export default function ProductList() { export default function ProductList() {
const qc = useQueryClient() const qc = useQueryClient()
const [keyword, setKeyword] = useState('') const [keyword, setKeyword] = useState('')
const [modal, setModal] = useState<any>(null)
const { data: products = [], isLoading } = useQuery({ const { data: products = [], isLoading } = useQuery({
queryKey: ['products', keyword], queryKey: ['products', keyword],
queryFn: () => getProducts({ keyword: keyword || undefined }), 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({ const deleteMut = useMutation({
mutationFn: deleteProduct, mutationFn: deleteProduct,
onSuccess: () => qc.invalidateQueries({ queryKey: ['products'] }), onSuccess: () => qc.invalidateQueries({ queryKey: ['products'] }),
@ -23,9 +29,15 @@ export default function ProductList() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-white">/ </h1> <h1 className="text-xl font-semibold text-white">/ </h1>
<div className="flex gap-2">
<input value={keyword} onChange={e => setKeyword(e.target.value)} <input value={keyword} onChange={e => setKeyword(e.target.value)}
placeholder="상품명·코드 검색" placeholder="상품명·코드 검색"
className="bg-panel border border-edge rounded px-3 py-1.5 text-sm text-gray-300 w-48" /> 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>
<div className="bg-card border border-edge rounded-lg overflow-hidden"> <div className="bg-card border border-edge rounded-lg overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
@ -53,8 +65,11 @@ export default function ProductList() {
</span> </span>
</td> </td>
<td className="px-3 py-3"> <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) }} <button onClick={() => { if(confirm('삭제?')) deleteMut.mutate(p.id) }}
className="text-red-400 hover:text-red-300"><Trash2 size={14} /></button> className="text-red-400 hover:text-red-300"><Trash2 size={14} /></button>
</div>
</td> </td>
</tr> </tr>
))} ))}
@ -64,6 +79,90 @@ export default function ProductList() {
</tbody> </tbody>
</table> </table>
</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>
<ProductForm initial={modal} onSave={(d: any) => saveMut.mutate(d)} />
</div>
</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>
)
}

View 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>
)
}

View File

@ -38,7 +38,7 @@ export default function TagBindingList() {
const { data: bindings = [], isLoading } = useQuery<TagBinding[]>({ const { data: bindings = [], isLoading } = useQuery<TagBinding[]>({
queryKey: ['tag-binding', tenantCode, storeId, keyword], queryKey: ['tag-binding', tenantCode, storeId, keyword],
queryFn: () => api.get('/tag-binding', { params: { queryFn: () => api.get('/api/tag-binding', { params: {
tenantCode: tenantCode || undefined, tenantCode: tenantCode || undefined,
storeId: storeId || undefined, storeId: storeId || undefined,
keyword: keyword || undefined, keyword: keyword || undefined,
@ -48,7 +48,7 @@ export default function TagBindingList() {
}) })
const bindMut = useMutation({ const bindMut = useMutation({
mutationFn: (data: BindForm) => api.post('/tag-binding', { mutationFn: (data: BindForm) => api.post('/api/tag-binding', {
...data, ...data,
storeId: Number(data.storeId), storeId: Number(data.storeId),
}), }),
@ -56,7 +56,7 @@ export default function TagBindingList() {
}) })
const unbindMut = useMutation({ 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'] }), onSuccess: () => qc.invalidateQueries({ queryKey: ['tag-binding'] }),
}) })

View File

@ -1,14 +1,22 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Trash2 } from 'lucide-react' import { Plus, Edit, Trash2, X } from 'lucide-react'
import { getTemplates, createTemplate, deleteTemplate } from '../api/client' import { getTemplates, createTemplate, updateTemplate, deleteTemplate } from '../api/client'
export default function TemplateList() { export default function TemplateList() {
const qc = useQueryClient() const qc = useQueryClient()
const [modal, setModal] = useState<any>(null)
const { data: templates = [], isLoading } = useQuery({ const { data: templates = [], isLoading } = useQuery({
queryKey: ['templates'], queryKey: ['templates'],
queryFn: () => getTemplates(), 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({ const deleteMut = useMutation({
mutationFn: deleteTemplate, mutationFn: deleteTemplate,
onSuccess: () => qc.invalidateQueries({ queryKey: ['templates'] }), onSuccess: () => qc.invalidateQueries({ queryKey: ['templates'] }),
@ -20,6 +28,10 @@ export default function TemplateList() {
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-white">ESL 릿</h1> <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>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{(templates as any[]).map((t: any) => ( {(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-sm font-medium text-white">{t.templateName}</p>
<p className="text-xs text-brand mt-0.5">{t.tenantCode} · {t.templateType}</p> <p className="text-xs text-brand mt-0.5">{t.tenantCode} · {t.templateType}</p>
</div> </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) }} <button onClick={() => { if(confirm('삭제?')) deleteMut.mutate(t.id) }}
className="text-red-400 hover:text-red-300"><Trash2 size={14} /></button> className="text-red-400 hover:text-red-300"><Trash2 size={14} /></button>
</div> </div>
</div>
<div className="bg-panel border border-edge rounded p-3 flex items-center justify-center" <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) }}> style={{ height: Math.min((t.height || 76) / 2 + 40, 120) }}>
<div className="text-center"> <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 className="col-span-3 text-center text-gray-500 py-8">릿 </div>
)} )}
</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> </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>
)
}

View File

@ -1,14 +1,22 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { getTenants, deleteTenant } from '../api/client' import { getTenants, createTenant, updateTenant, deleteTenant } from '../api/client'
import { Building2, Trash2 } from 'lucide-react' import { Building2, Plus, Edit, Trash2, X } from 'lucide-react'
export default function TenantAdmin() { export default function TenantAdmin() {
const qc = useQueryClient() const qc = useQueryClient()
const [modal, setModal] = useState<any>(null)
const { data: tenants = [], isLoading } = useQuery({ const { data: tenants = [], isLoading } = useQuery({
queryKey: ['tenants'], queryKey: ['tenants'],
queryFn: () => getTenants(), 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({ const deleteMut = useMutation({
mutationFn: deleteTenant, mutationFn: deleteTenant,
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenants'] }), onSuccess: () => qc.invalidateQueries({ queryKey: ['tenants'] }),
@ -25,7 +33,13 @@ export default function TenantAdmin() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-white"> </h1> <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"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{(tenants as any[]).map((t: any) => ( {(tenants as any[]).map((t: any) => (
<div key={t.id} className="bg-card border border-edge rounded-lg p-4"> <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" /> <Building2 size={18} className="text-brand" />
<span className="font-bold text-brand">{t.tenantCode}</span> <span className="font-bold text-brand">{t.tenantCode}</span>
</div> </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) }} <button onClick={() => { if(confirm('삭제?')) deleteMut.mutate(t.id) }}
className="text-red-400 hover:text-red-300"><Trash2 size={14} /></button> className="text-red-400 hover:text-red-300"><Trash2 size={14} /></button>
</div> </div>
</div>
<p className="text-sm text-white">{t.tenantName}</p> <p className="text-sm text-white">{t.tenantName}</p>
<p className="text-xs text-gray-400 mt-1">{t.description}</p> <p className="text-xs text-gray-400 mt-1">{t.description}</p>
<div className="mt-3"> <div className="mt-3">
@ -46,7 +63,72 @@ export default function TenantAdmin() {
</div> </div>
</div> </div>
))} ))}
{(tenants as any[]).length === 0 && (
<div className="col-span-full text-center text-gray-500 py-12"> </div>
)}
</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> </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>
)
}

View File

@ -27,7 +27,7 @@ export default function UpdateQueueList() {
const { data: items = [], isLoading, refetch } = useQuery<QueueItem[]>({ const { data: items = [], isLoading, refetch } = useQuery<QueueItem[]>({
queryKey: ['update-queue', tenantCode, status], queryKey: ['update-queue', tenantCode, status],
queryFn: () => api.get('/update-queue', { params: { queryFn: () => api.get('/api/update-queue', { params: {
tenantCode: tenantCode || undefined, tenantCode: tenantCode || undefined,
status: status || undefined, status: status || undefined,
}}).then(r => r.data.data), }}).then(r => r.data.data),
@ -35,7 +35,7 @@ export default function UpdateQueueList() {
}) })
const cancelMut = useMutation({ 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'] }), onSuccess: () => qc.invalidateQueries({ queryKey: ['update-queue'] }),
}) })

View File

@ -1,14 +1,22 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Trash2 } from 'lucide-react' import { Plus, Edit, Trash2, X } from 'lucide-react'
import { getUsers, deleteUser } from '../api/client' import { getUsers, createUser, updateUser, deleteUser } from '../api/client'
export default function UserList() { export default function UserList() {
const qc = useQueryClient() const qc = useQueryClient()
const [modal, setModal] = useState<any>(null)
const { data: users = [], isLoading } = useQuery({ const { data: users = [], isLoading } = useQuery({
queryKey: ['users'], queryKey: ['users'],
queryFn: () => getUsers(), 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({ const deleteMut = useMutation({
mutationFn: deleteUser, mutationFn: deleteUser,
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }), onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
@ -18,7 +26,13 @@ export default function UserList() {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-white"> </h1> <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"> <div className="bg-card border border-edge rounded-lg overflow-hidden">
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-panel border-b border-edge"> <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') : '-'} {u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString('ko-KR') : '-'}
</td> </td>
<td className="px-4 py-3"> <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) }} <button onClick={() => { if(confirm('삭제?')) deleteMut.mutate(u.id) }}
className="text-red-400 hover:text-red-300"><Trash2 size={14} /></button> className="text-red-400 hover:text-red-300"><Trash2 size={14} /></button>
</div>
</td> </td>
</tr> </tr>
))} ))}
{(users as any[]).length === 0 && (
<tr><td colSpan={8} className="px-4 py-8 text-center text-gray-500"> </td></tr>
)}
</tbody> </tbody>
</table> </table>
</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>
<UserForm initial={modal} onSave={(d: any) => saveMut.mutate(d)} />
</div>
</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>
)
}