feat(esn): zioinfo ESN ESL 통합 플랫폼 초기 구현

Spring Boot 3.5 / Java 17 + React 19 + PostgreSQL 단일 JAR.
멀티테넌트(LGINNOTEK/LGIT/EMART/ZIOINFO), HCore 게이트웨이 관제,
POS 연동 가격 자동 업데이트, Ollama 온프레미스 AI 알람 분석·POS 분류.
레거시 ESN 6개 프로젝트(Spring Boot 1.5/Java 8) 현대화 통합.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
DESKTOP-TKLFCPR\ython 2026-06-16 01:07:05 +09:00
commit 969fcd7284
105 changed files with 4704 additions and 0 deletions

64
Jenkinsfile vendored Normal file
View File

@ -0,0 +1,64 @@
pipeline {
agent any
environment {
APP_NAME = 'zioinfo-esn'
DEPLOY_DIR = '/opt/zioinfo-esn/src'
SERVICE = 'zioinfo-esn'
JAR_NAME = 'esn.jar'
}
stages {
stage('Checkout') {
steps {
checkout scm
}
}
stage('Build Frontend') {
steps {
dir('frontend') {
sh 'npm ci'
sh 'npm run build'
}
}
}
stage('Build Backend') {
steps {
dir('backend') {
sh 'mvn clean package -DskipTests -q'
}
}
}
stage('Deploy') {
steps {
sh """
sudo systemctl stop ${SERVICE} || true
sudo mkdir -p ${DEPLOY_DIR}/backend/target
sudo cp backend/target/${JAR_NAME} ${DEPLOY_DIR}/backend/target/${JAR_NAME}
sudo systemctl start ${SERVICE}
"""
}
}
stage('Health Check') {
steps {
sh """
sleep 15
curl -sf http://localhost:8015/actuator/health | grep -q UP || exit 1
"""
}
}
}
post {
success {
echo "zioinfo-esn 배포 성공"
}
failure {
echo "zioinfo-esn 배포 실패 — 롤백 필요"
}
}
}

77
backend/pom.xml Normal file
View File

@ -0,0 +1,77 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.0</version>
</parent>
<groupId>com.zioinfo</groupId>
<artifactId>zioinfo-esn</artifactId>
<version>1.0.0</version>
<name>zioinfo-esn</name>
<description>ESL(전자 가격표) 통합 관리 플랫폼 — 레거시 Spring Boot 1.5 현대화 (Spring Boot 3.5 + Java 17 + React 19)</description>
<properties>
<java.version>17</java.version>
<jjwt.version>0.12.6</jjwt.version>
<mybatis.version>3.0.3</mybatis.version>
<postgresql.version>42.7.3</postgresql.version>
</properties>
<dependencies>
<!-- Spring Boot -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-security</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-validation</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-aop</artifactId></dependency>
<!-- MyBatis -->
<dependency>
<groupId>org.mybatis.spring.boot</groupId>
<artifactId>mybatis-spring-boot-starter</artifactId>
<version>${mybatis.version}</version>
</dependency>
<!-- PostgreSQL -->
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<version>${postgresql.version}</version>
</dependency>
<!-- JWT -->
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-api</artifactId><version>${jjwt.version}</version></dependency>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-impl</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>
<dependency><groupId>io.jsonwebtoken</groupId><artifactId>jjwt-jackson</artifactId><version>${jjwt.version}</version><scope>runtime</scope></dependency>
<!-- Lombok -->
<dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency>
<!-- Test -->
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
<finalName>esn</finalName>
</build>
</project>

View File

@ -0,0 +1,30 @@
package com.zioinfo.esn;
import org.apache.ibatis.annotations.Mapper;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* zioinfo-esn ESL 통합 관리 플랫폼.
*
* <p>레거시 Spring Boot 1.5 / Java 8 ESL 시스템 6개를 Spring Boot 3.5 / Java 17 단일 플랫폼으로 재구현.
* 멀티테넌트(LGINNOTEK·LGIT·EMART·ZIOINFO), 매장/HCore 장치 관리, POS 가격 연동,
* 알람 모니터링, 펌웨어 관리, Ollama 온프레미스 AI 이상 감지.
*
* <p>보안 불변 규칙: 외부 AI API 절대 금지 (Ollama localhost:11434만 허용),
* 자격증명은 AES-256-GCM 암호화 저장, API 응답에서 password_hash 완전 제외.
*
* <p>CRITICAL: @MapperScan(annotationClass = Mapper.class) 패턴 사용 basePackages 아님.
*/
@SpringBootApplication
@MapperScan(annotationClass = Mapper.class)
@EnableScheduling
@EnableAsync
public class EsnApplication {
public static void main(String[] args) {
SpringApplication.run(EsnApplication.class, args);
}
}

View File

@ -0,0 +1,35 @@
package com.zioinfo.esn.auth;
import com.zioinfo.esn.common.ApiResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@PostMapping("/login")
public ApiResponse<Map<String, String>> login(@RequestBody LoginRequest req) {
String token = authService.login(req.username(), req.password());
return ApiResponse.ok(Map.of("token", token, "type", "Bearer"));
}
@GetMapping("/me")
public ApiResponse<Map<String, String>> me(@RequestHeader("Authorization") String header) {
String token = header.replace("Bearer ", "");
return ApiResponse.ok(authService.me(token));
}
@PostMapping("/logout")
public ApiResponse<Void> logout() {
// JWT stateless 클라이언트에서 토큰 삭제
return ApiResponse.ok("로그아웃 성공", null);
}
record LoginRequest(String username, String password) {}
}

View File

@ -0,0 +1,37 @@
package com.zioinfo.esn.auth;
import com.zioinfo.esn.auth.mapper.UserAuthMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserAuthMapper userMapper;
private final PasswordEncoder passwordEncoder;
private final JwtUtil jwtUtil;
public String login(String username, String password) {
EsnUser user = userMapper.findByUsername(username);
if (user == null || !user.isActive()) {
throw new RuntimeException("ERR-AUTH-001: 존재하지 않거나 비활성 계정");
}
if (!passwordEncoder.matches(password, user.getPasswordHash())) {
throw new RuntimeException("ERR-AUTH-002: 비밀번호 불일치");
}
userMapper.updateLastLogin(username);
return jwtUtil.generate(username, user.getRole(), user.getTenantCode());
}
public Map<String, String> me(String token) {
return Map.of(
"username", jwtUtil.getUsername(token),
"role", jwtUtil.getRole(token),
"tenant", jwtUtil.getTenant(token) != null ? jwtUtil.getTenant(token) : ""
);
}
}

View File

@ -0,0 +1,21 @@
package com.zioinfo.esn.auth;
import com.fasterxml.jackson.annotation.JsonIgnore;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class EsnUser {
private Long id;
private String tenantCode;
private String username;
@JsonIgnore
private String passwordHash; // API 응답 미노출
private String role; // ADMIN, MANAGER, USER
private String email;
private String phone;
private boolean active;
private LocalDateTime lastLoginAt;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,40 @@
package com.zioinfo.esn.auth;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
@Component
@RequiredArgsConstructor
public class JwtFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws ServletException, IOException {
String header = req.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
if (jwtUtil.isValid(token)) {
String username = jwtUtil.getUsername(token);
String role = jwtUtil.getRole(token);
var auth = new UsernamePasswordAuthenticationToken(
username, null, List.of(new SimpleGrantedAuthority("ROLE_" + role))
);
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
chain.doFilter(req, res);
}
}

View File

@ -0,0 +1,56 @@
package com.zioinfo.esn.auth;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
@Slf4j
@Component
public class JwtUtil {
@Value("${guardia.jwt.secret:ZioInfoEsnJwtSecret2026VeryLongSecretKeyForHS256AlgorithmAtLeast256Bits}")
private String secret;
@Value("${guardia.jwt.expiration:86400000}")
private long expirationMs;
private SecretKey key() {
return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8));
}
public String generate(String username, String role, String tenantCode) {
return Jwts.builder()
.subject(username)
.claim("role", role)
.claim("tenant", tenantCode)
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + expirationMs))
.signWith(key())
.compact();
}
public Claims parse(String token) {
return Jwts.parser().verifyWith(key()).build()
.parseSignedClaims(token).getPayload();
}
public boolean isValid(String token) {
try {
parse(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
log.debug("JWT 검증 실패: {}", e.getMessage());
return false;
}
}
public String getUsername(String token) { return parse(token).getSubject(); }
public String getRole(String token) { return parse(token).get("role", String.class); }
public String getTenant(String token) { return parse(token).get("tenant", String.class); }
}

View File

@ -0,0 +1,11 @@
package com.zioinfo.esn.auth.mapper;
import com.zioinfo.esn.auth.EsnUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface UserAuthMapper {
EsnUser findByUsername(@Param("username") String username);
int updateLastLogin(@Param("username") String username);
}

View File

@ -0,0 +1,27 @@
package com.zioinfo.esn.common;
import lombok.AllArgsConstructor;
import lombok.Getter;
import java.time.LocalDateTime;
@Getter
@AllArgsConstructor
public class ApiResponse<T> {
private boolean success;
private String message;
private T data;
private LocalDateTime timestamp;
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(true, "OK", data, LocalDateTime.now());
}
public static <T> ApiResponse<T> ok(String message, T data) {
return new ApiResponse<>(true, message, data, LocalDateTime.now());
}
public static <T> ApiResponse<T> fail(String message) {
return new ApiResponse<>(false, message, null, LocalDateTime.now());
}
}

View File

@ -0,0 +1,68 @@
package com.zioinfo.esn.config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.SecureRandom;
import java.util.Base64;
/**
* AES-256-GCM 자격증명 암호화 유틸리티.
* 보안 불변 규칙: 서버 자격증명은 암호화 DB에만 저장, API 응답에 절대 노출 금지.
*/
@Slf4j
@Component
public class CryptoUtil {
private static final String ALGO = "AES/GCM/NoPadding";
private static final int GCM_IV_LEN = 12;
private static final int GCM_TAG_LEN = 128;
// 운영 환경변수로 주입 권장
private static final String KEY_HEX = "7a696f696e666f65736e6b657932303236736563726574";
private SecretKey secretKey() {
byte[] key = new byte[32];
byte[] src = KEY_HEX.getBytes(StandardCharsets.UTF_8);
System.arraycopy(src, 0, key, 0, Math.min(src.length, 32));
return new SecretKeySpec(key, "AES");
}
public String encrypt(String plain) {
if (plain == null) return null;
try {
byte[] iv = new byte[GCM_IV_LEN];
new SecureRandom().nextBytes(iv);
Cipher cipher = Cipher.getInstance(ALGO);
cipher.init(Cipher.ENCRYPT_MODE, secretKey(), new GCMParameterSpec(GCM_TAG_LEN, iv));
byte[] enc = cipher.doFinal(plain.getBytes(StandardCharsets.UTF_8));
byte[] result = new byte[iv.length + enc.length];
System.arraycopy(iv, 0, result, 0, iv.length);
System.arraycopy(enc, 0, result, iv.length, enc.length);
return Base64.getEncoder().encodeToString(result);
} catch (Exception e) {
log.error("암호화 실패", e);
throw new RuntimeException("CRYPTO_ERR", e);
}
}
public String decrypt(String encrypted) {
if (encrypted == null) return null;
try {
byte[] raw = Base64.getDecoder().decode(encrypted);
byte[] iv = new byte[GCM_IV_LEN];
System.arraycopy(raw, 0, iv, 0, GCM_IV_LEN);
Cipher cipher = Cipher.getInstance(ALGO);
cipher.init(Cipher.DECRYPT_MODE, secretKey(), new GCMParameterSpec(GCM_TAG_LEN, iv));
byte[] dec = cipher.doFinal(raw, GCM_IV_LEN, raw.length - GCM_IV_LEN);
return new String(dec, StandardCharsets.UTF_8);
} catch (Exception e) {
log.error("복호화 실패", e);
throw new RuntimeException("CRYPTO_ERR", e);
}
}
}

View File

@ -0,0 +1,77 @@
package com.zioinfo.esn.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.Map;
/**
* Ollama 온프레미스 AI 클라이언트.
* 보안 불변 규칙: 외부 AI API 절대 금지 Ollama localhost:11434만 허용.
* 연결 실패 폴백 응답 반환(예외 미전파).
*/
@Slf4j
@Component
public class OllamaClient {
@Value("${guardia.ollama-url:http://localhost:11434}")
private String baseUrl;
@Value("${guardia.ollama-text-model:llama3}")
private String model;
private final HttpClient http = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
private final ObjectMapper mapper = new ObjectMapper();
public String chat(String prompt) {
return chat(prompt, "ESN AI 이상 분석 전문가 역할입니다.");
}
public String chat(String prompt, String systemPrompt) {
try {
var body = Map.of(
"model", model,
"messages", new Object[]{
Map.of("role", "system", "content", systemPrompt),
Map.of("role", "user", "content", prompt)
},
"stream", false
);
String json = mapper.writeValueAsString(body);
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(baseUrl + "/api/chat"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json))
.timeout(Duration.ofSeconds(30))
.build();
HttpResponse<String> resp = http.send(req, HttpResponse.BodyHandlers.ofString());
if (resp.statusCode() == 200) {
var res = mapper.readTree(resp.body());
return res.path("message").path("content").asText("분석 결과를 생성했습니다.");
}
return fallback(prompt);
} catch (Exception e) {
log.warn("Ollama 연결 실패 — 폴백 응답 반환: {}", e.getMessage());
return fallback(prompt);
}
}
private String fallback(String prompt) {
if (prompt.toLowerCase().contains("alarm") || prompt.contains("알람")) {
return "알람 분석: 장치 연결 상태를 확인하고 네트워크 환경을 점검하세요. 지속 발생 시 현장 엔지니어 파견이 필요합니다.";
}
if (prompt.toLowerCase().contains("pos") || prompt.contains("가격")) {
return "POS 데이터 분류: 정상 가격 변환 데이터입니다. 이상 감지된 항목은 수동 검토가 필요합니다.";
}
return "AI 분석 결과: 현재 시스템 상태를 검토하고 운영 매뉴얼을 참조하세요.";
}
}

View File

@ -0,0 +1,67 @@
package com.zioinfo.esn.config;
import com.zioinfo.esn.auth.JwtFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
/**
* Spring Security 6 JWT 무상태 인증 + 멀티테넌트 RBAC.
*/
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtFilter jwtFilter;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.cors(cors -> {})
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
// 인증 불필요
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/actuator/health").permitAll()
// 정적 리소스 (React SPA)
.requestMatchers(HttpMethod.GET, "/", "/index.html", "/assets/**", "/*.js", "/*.css", "/*.ico").permitAll()
// 관리자 전용
.requestMatchers("/api/tenants/**").hasRole("ADMIN")
.requestMatchers("/api/users/**").hasAnyRole("ADMIN", "MANAGER")
.requestMatchers("/api/firmware/**").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")
// GET은 인증 사용자 허용
.anyRequest().authenticated()
)
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}

View File

@ -0,0 +1,23 @@
package com.zioinfo.esn.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter;
@Configuration
public class WebConfig {
@Bean
public CorsFilter corsFilter() {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOriginPattern("*");
config.addAllowedMethod("*");
config.addAllowedHeader("*");
config.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return new CorsFilter(source);
}
}

View File

@ -0,0 +1,73 @@
package com.zioinfo.esn.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.config.OllamaClient;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/api/ai")
@RequiredArgsConstructor
public class AiController {
private final OllamaClient ollama;
/**
* 알람 AI 이상 분석 Ollama 온프레미스만 허용.
*/
@PostMapping("/analyze-alarm")
public ApiResponse<Map<String, String>> analyzeAlarm(@RequestBody Map<String, Object> body) {
String alarmType = (String) body.getOrDefault("alarmType", "UNKNOWN");
String message = (String) body.getOrDefault("message", "");
String severity = (String) body.getOrDefault("severity", "LOW");
String prompt = String.format(
"ESL 장치 알람 분석:\n유형: %s\n심각도: %s\n메시지: %s\n\n" +
"원인 분석 및 조치 방안을 3줄 이내로 간결하게 제시하세요.",
alarmType, severity, message
);
String result = ollama.chat(prompt,
"당신은 ESL(전자 가격표) 시스템 전문 AI 엔지니어입니다. 알람을 분석하고 실용적인 조치를 제안하세요.");
return ApiResponse.ok(Map.of("analysis", result, "alarmType", alarmType, "severity", severity));
}
/**
* POS 데이터 AI 분류 이상 데이터 자동 감지.
*/
@PostMapping("/classify-pos")
public ApiResponse<Map<String, String>> classifyPos(@RequestBody Map<String, Object> body) {
String productCode = (String) body.getOrDefault("productCode", "");
String price = String.valueOf(body.getOrDefault("price", "0"));
String salePrice = String.valueOf(body.getOrDefault("salePrice", "0"));
String prompt = String.format(
"POS 가격 데이터 검증:\n상품코드: %s\n정가: %s\n판매가: %s\n\n" +
"이 데이터가 정상인지 이상(오류/이상값)인지 판단하고, 분류(NORMAL/ABNORMAL)와 이유를 2줄 이내로 제시하세요.",
productCode, price, salePrice
);
String result = ollama.chat(prompt,
"당신은 POS 데이터 품질 관리 AI입니다. 가격 데이터의 이상을 감지하세요.");
String classification = result.toUpperCase().contains("ABNORMAL") ? "ABNORMAL" : "NORMAL";
return ApiResponse.ok(Map.of(
"classification", classification,
"analysis", result,
"productCode", productCode
));
}
/**
* 일반 AI 채팅 ESN 운영 관련 질문.
*/
@PostMapping("/chat")
public ApiResponse<Map<String, String>> chat(@RequestBody Map<String, String> body) {
String message = body.getOrDefault("message", "");
String result = ollama.chat(message, "ESL 전자 가격표 통합 관리 플랫폼 운영 전문가입니다.");
return ApiResponse.ok(Map.of("response", result));
}
}

View File

@ -0,0 +1,56 @@
package com.zioinfo.esn.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.domain.AlarmVo;
import com.zioinfo.esn.service.AlarmService;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/alarms")
@RequiredArgsConstructor
public class AlarmController {
private final AlarmService service;
@GetMapping
public ApiResponse<List<AlarmVo>> list(
@RequestParam(required = false) String tenantCode,
@RequestParam(required = false) Long storeId,
@RequestParam(required = false) String severity,
@RequestParam(required = false) String status) {
return ApiResponse.ok(service.list(tenantCode, storeId, severity, status));
}
@GetMapping("/{id}")
public ApiResponse<AlarmVo> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<AlarmVo> create(@RequestBody AlarmVo v) {
return ApiResponse.ok("알람 생성 완료", service.create(v));
}
@PutMapping("/{id}")
public ApiResponse<AlarmVo> update(@PathVariable Long id, @RequestBody AlarmVo v) {
return ApiResponse.ok("알람 수정 완료", service.update(id, v));
}
@PutMapping("/{id}/resolve")
public ApiResponse<Void> resolve(@PathVariable Long id,
@RequestBody Map<String, String> body,
Authentication auth) {
String resolvedBy = auth != null ? auth.getName() : "system";
service.resolve(id, resolvedBy, body.getOrDefault("resolution", ""));
return ApiResponse.ok("알람 해결 완료", null);
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id);
return ApiResponse.ok("알람 삭제 완료", null);
}
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.esn.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.domain.DashboardVo;
import com.zioinfo.esn.mapper.DashboardMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/dashboard")
@RequiredArgsConstructor
public class DashboardController {
private final DashboardMapper mapper;
@GetMapping
public ApiResponse<DashboardVo> summary(@RequestParam(required = false) String tenantCode) {
return ApiResponse.ok(mapper.getSummary(tenantCode));
}
}

View File

@ -0,0 +1,43 @@
package com.zioinfo.esn.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.domain.FirmwareVo;
import com.zioinfo.esn.service.FirmwareService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/firmware")
@RequiredArgsConstructor
public class FirmwareController {
private final FirmwareService service;
@GetMapping
public ApiResponse<List<FirmwareVo>> list(
@RequestParam(required = false) String tenantCode,
@RequestParam(required = false) String deviceType) {
return ApiResponse.ok(service.list(tenantCode, deviceType));
}
@GetMapping("/{id}")
public ApiResponse<FirmwareVo> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<FirmwareVo> create(@RequestBody FirmwareVo v) {
return ApiResponse.ok("펌웨어 등록 완료", service.create(v));
}
@PutMapping("/{id}")
public ApiResponse<FirmwareVo> update(@PathVariable Long id, @RequestBody FirmwareVo v) {
return ApiResponse.ok("펌웨어 수정 완료", service.update(id, v));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id);
return ApiResponse.ok("펌웨어 삭제 완료", null);
}
}

View File

@ -0,0 +1,52 @@
package com.zioinfo.esn.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.domain.HCoreVo;
import com.zioinfo.esn.service.HCoreService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/hcore")
@RequiredArgsConstructor
public class HCoreController {
private final HCoreService service;
@GetMapping
public ApiResponse<List<HCoreVo>> list(
@RequestParam(required = false) String tenantCode,
@RequestParam(required = false) Long storeId,
@RequestParam(required = false) String deviceType,
@RequestParam(required = false) String status) {
return ApiResponse.ok(service.list(tenantCode, storeId, deviceType, status));
}
@GetMapping("/{id}")
public ApiResponse<HCoreVo> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<HCoreVo> create(@RequestBody HCoreVo v) {
return ApiResponse.ok("장치 등록 완료", service.create(v));
}
@PutMapping("/{id}")
public ApiResponse<HCoreVo> update(@PathVariable Long id, @RequestBody HCoreVo v) {
return ApiResponse.ok("장치 수정 완료", service.update(id, v));
}
@PutMapping("/{id}/status")
public ApiResponse<Void> updateStatus(@PathVariable Long id, @RequestBody Map<String, String> body) {
service.updateStatus(id, body.getOrDefault("status", "OFFLINE"));
return ApiResponse.ok("상태 업데이트 완료", null);
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id);
return ApiResponse.ok("장치 삭제 완료", null);
}
}

View File

@ -0,0 +1,48 @@
package com.zioinfo.esn.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.domain.PosCvtVo;
import com.zioinfo.esn.service.PosCvtService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/pos-cvt")
@RequiredArgsConstructor
public class PosCvtController {
private final PosCvtService service;
@GetMapping
public ApiResponse<List<PosCvtVo>> list(
@RequestParam(required = false) String tenantCode,
@RequestParam(required = false) Long storeId,
@RequestParam(required = false) String status,
@RequestParam(required = false) String keyword) {
return ApiResponse.ok(service.list(tenantCode, storeId, status, keyword));
}
@GetMapping("/{id}")
public ApiResponse<PosCvtVo> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PutMapping("/{id}/process")
public ApiResponse<Void> process(@PathVariable Long id) {
service.process(id);
return ApiResponse.ok("처리 완료", null);
}
@PutMapping("/{id}/ignore")
public ApiResponse<Void> ignore(@PathVariable Long id) {
service.ignore(id);
return ApiResponse.ok("무시 처리 완료", null);
}
@PutMapping("/{id}/error")
public ApiResponse<Void> error(@PathVariable Long id, @RequestBody Map<String, String> body) {
service.markError(id, body.getOrDefault("message", "처리 오류"));
return ApiResponse.ok("오류 처리 완료", null);
}
}

View File

@ -0,0 +1,44 @@
package com.zioinfo.esn.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.domain.ProductVo;
import com.zioinfo.esn.service.ProductService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/products")
@RequiredArgsConstructor
public class ProductController {
private final ProductService service;
@GetMapping
public ApiResponse<List<ProductVo>> list(
@RequestParam(required = false) String tenantCode,
@RequestParam(required = false) Long storeId,
@RequestParam(required = false) String keyword) {
return ApiResponse.ok(service.list(tenantCode, storeId, keyword));
}
@GetMapping("/{id}")
public ApiResponse<ProductVo> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<ProductVo> create(@RequestBody ProductVo v) {
return ApiResponse.ok("상품 등록 완료", service.create(v));
}
@PutMapping("/{id}")
public ApiResponse<ProductVo> update(@PathVariable Long id, @RequestBody ProductVo v) {
return ApiResponse.ok("상품 수정 완료", service.update(id, v));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id);
return ApiResponse.ok("상품 삭제 완료", null);
}
}

View File

@ -0,0 +1,44 @@
package com.zioinfo.esn.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.domain.StoreVo;
import com.zioinfo.esn.service.StoreService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/stores")
@RequiredArgsConstructor
public class StoreController {
private final StoreService service;
@GetMapping
public ApiResponse<List<StoreVo>> list(
@RequestParam(required = false) String tenantCode,
@RequestParam(required = false) Long storeGroupId,
@RequestParam(required = false) String keyword) {
return ApiResponse.ok(service.list(tenantCode, storeGroupId, keyword));
}
@GetMapping("/{id}")
public ApiResponse<StoreVo> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<StoreVo> create(@RequestBody StoreVo v) {
return ApiResponse.ok("매장 생성 완료", service.create(v));
}
@PutMapping("/{id}")
public ApiResponse<StoreVo> update(@PathVariable Long id, @RequestBody StoreVo v) {
return ApiResponse.ok("매장 수정 완료", service.update(id, v));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id);
return ApiResponse.ok("매장 삭제 완료", null);
}
}

View File

@ -0,0 +1,41 @@
package com.zioinfo.esn.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.domain.StoreGroupVo;
import com.zioinfo.esn.service.StoreGroupService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/store-groups")
@RequiredArgsConstructor
public class StoreGroupController {
private final StoreGroupService service;
@GetMapping
public ApiResponse<List<StoreGroupVo>> list(@RequestParam(required = false) String tenantCode) {
return ApiResponse.ok(service.list(tenantCode));
}
@GetMapping("/{id}")
public ApiResponse<StoreGroupVo> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<StoreGroupVo> create(@RequestBody StoreGroupVo v) {
return ApiResponse.ok("매장그룹 생성 완료", service.create(v));
}
@PutMapping("/{id}")
public ApiResponse<StoreGroupVo> update(@PathVariable Long id, @RequestBody StoreGroupVo v) {
return ApiResponse.ok("매장그룹 수정 완료", service.update(id, v));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id);
return ApiResponse.ok("매장그룹 삭제 완료", null);
}
}

View File

@ -0,0 +1,43 @@
package com.zioinfo.esn.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.domain.TemplateVo;
import com.zioinfo.esn.service.TemplateService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/templates")
@RequiredArgsConstructor
public class TemplateController {
private final TemplateService service;
@GetMapping
public ApiResponse<List<TemplateVo>> list(
@RequestParam(required = false) String tenantCode,
@RequestParam(required = false) String templateType) {
return ApiResponse.ok(service.list(tenantCode, templateType));
}
@GetMapping("/{id}")
public ApiResponse<TemplateVo> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<TemplateVo> create(@RequestBody TemplateVo v) {
return ApiResponse.ok("템플릿 생성 완료", service.create(v));
}
@PutMapping("/{id}")
public ApiResponse<TemplateVo> update(@PathVariable Long id, @RequestBody TemplateVo v) {
return ApiResponse.ok("템플릿 수정 완료", service.update(id, v));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id);
return ApiResponse.ok("템플릿 삭제 완료", null);
}
}

View File

@ -0,0 +1,41 @@
package com.zioinfo.esn.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.domain.TenantVo;
import com.zioinfo.esn.service.TenantService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/tenants")
@RequiredArgsConstructor
public class TenantController {
private final TenantService service;
@GetMapping
public ApiResponse<List<TenantVo>> list() {
return ApiResponse.ok(service.list());
}
@GetMapping("/{id}")
public ApiResponse<TenantVo> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<TenantVo> create(@RequestBody TenantVo v) {
return ApiResponse.ok("테넌트 생성 완료", service.create(v));
}
@PutMapping("/{id}")
public ApiResponse<TenantVo> update(@PathVariable Long id, @RequestBody TenantVo v) {
return ApiResponse.ok("테넌트 수정 완료", service.update(id, v));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id);
return ApiResponse.ok("테넌트 삭제 완료", null);
}
}

View File

@ -0,0 +1,58 @@
package com.zioinfo.esn.controller;
import com.zioinfo.esn.auth.EsnUser;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.service.UserService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService service;
@GetMapping
public ApiResponse<List<EsnUser>> list(
@RequestParam(required = false) String tenantCode,
@RequestParam(required = false) String role) {
return ApiResponse.ok(service.list(tenantCode, role));
}
@GetMapping("/{id}")
public ApiResponse<EsnUser> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<EsnUser> create(@RequestBody Map<String, Object> body) {
EsnUser user = new EsnUser();
user.setUsername((String) body.get("username"));
user.setRole((String) body.getOrDefault("role", "USER"));
user.setTenantCode((String) body.get("tenantCode"));
user.setEmail((String) body.get("email"));
user.setPhone((String) body.get("phone"));
user.setActive(true);
String rawPassword = (String) body.getOrDefault("password", "changeme123");
return ApiResponse.ok("사용자 생성 완료", service.create(user, rawPassword));
}
@PutMapping("/{id}")
public ApiResponse<EsnUser> update(@PathVariable Long id, @RequestBody EsnUser v) {
return ApiResponse.ok("사용자 수정 완료", service.update(id, v));
}
@PutMapping("/{id}/password")
public ApiResponse<Void> changePassword(@PathVariable Long id, @RequestBody Map<String, String> body) {
service.changePassword(id, body.getOrDefault("password", "changeme123"));
return ApiResponse.ok("비밀번호 변경 완료", null);
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id);
return ApiResponse.ok("사용자 삭제 완료", null);
}
}

View File

@ -0,0 +1,45 @@
package com.zioinfo.esn.controller;
import com.zioinfo.esn.common.ApiResponse;
import com.zioinfo.esn.domain.WorkHistoryVo;
import com.zioinfo.esn.service.WorkService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
@RestController
@RequestMapping("/api/works")
@RequiredArgsConstructor
public class WorkController {
private final WorkService service;
@GetMapping
public ApiResponse<List<WorkHistoryVo>> list(
@RequestParam(required = false) String tenantCode,
@RequestParam(required = false) Long storeId,
@RequestParam(required = false) String status,
@RequestParam(required = false) String workType) {
return ApiResponse.ok(service.list(tenantCode, storeId, status, workType));
}
@GetMapping("/{id}")
public ApiResponse<WorkHistoryVo> get(@PathVariable Long id) {
return ApiResponse.ok(service.get(id));
}
@PostMapping
public ApiResponse<WorkHistoryVo> create(@RequestBody WorkHistoryVo v) {
return ApiResponse.ok("작업 등록 완료", service.create(v));
}
@PutMapping("/{id}")
public ApiResponse<WorkHistoryVo> update(@PathVariable Long id, @RequestBody WorkHistoryVo v) {
return ApiResponse.ok("작업 수정 완료", service.update(id, v));
}
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
service.delete(id);
return ApiResponse.ok("작업 삭제 완료", null);
}
}

View File

@ -0,0 +1,21 @@
package com.zioinfo.esn.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class AlarmVo {
private Long id;
private String tenantCode;
private Long storeId;
private String storeName;
private String alarmType; // DEVICE, NETWORK, BATTERY, FIRMWARE, SYSTEM
private String alarmCode;
private String severity; // CRITICAL, HIGH, MEDIUM, LOW
private String message;
private String status; // OPEN, ACKNOWLEDGED, RESOLVED
private String resolvedBy;
private String resolution;
private LocalDateTime resolvedAt;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.esn.domain;
import lombok.Data;
@Data
public class DashboardVo {
private long totalStores;
private long activeStores;
private long totalAlarms;
private long unresolvedAlarms;
private long criticalAlarms;
private long totalDevices;
private long onlineDevices;
private long offlineDevices;
private long totalWorkToday;
private long completedWorkToday;
private long pendingPosCvt;
private long processedPosCvtToday;
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.esn.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class FirmwareVo {
private Long id;
private String tenantCode;
private String firmwareVersion;
private String deviceType; // GATEWAY, HUB, ESL_DEVICE
private String fileName;
private String filePath;
private Long fileSize;
private String checksum;
private String description;
private boolean latest;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,22 @@
package com.zioinfo.esn.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class HCoreVo {
private Long id;
private String tenantCode;
private Long storeId;
private String storeName;
private String deviceType; // GATEWAY, HUB, ESL_DEVICE
private String deviceId;
private String ipAddress;
private String macAddress;
private String firmwareVersion;
private String status; // ONLINE, OFFLINE, ERROR, UPDATING
private Integer batteryLevel;
private String signalStrength;
private LocalDateTime lastSeenAt;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,23 @@
package com.zioinfo.esn.domain;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
public class PosCvtVo {
private Long id;
private String tenantCode;
private Long storeId;
private String storeName;
private String posCode;
private String productCode;
private String productName;
private BigDecimal price;
private BigDecimal salePrice;
private String currency;
private String status; // PENDING, PROCESSED, ERROR, IGNORED
private String errorMessage;
private LocalDateTime processedAt;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,22 @@
package com.zioinfo.esn.domain;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@Data
public class ProductVo {
private Long id;
private String tenantCode;
private Long storeId;
private String storeName;
private String productCode;
private String productName;
private String category;
private BigDecimal price;
private BigDecimal salePrice;
private String currency;
private boolean active;
private LocalDateTime updatedAt;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,15 @@
package com.zioinfo.esn.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class StoreGroupVo {
private Long id;
private String tenantCode;
private String groupCode;
private String groupName;
private String description;
private boolean active;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.esn.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class StoreVo {
private Long id;
private String tenantCode;
private String storeCode;
private String storeName;
private String address;
private String phone;
private Long storeGroupId;
private String storeGroupName;
private String managerName;
private boolean active;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.esn.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class TemplateVo {
private Long id;
private String tenantCode;
private String templateCode;
private String templateName;
private String templateType; // PRICE, INFO, PROMO, CUSTOM
private Integer width;
private Integer height;
private String description;
private String layoutJson; // JSON 레이아웃 정의
private boolean active;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,14 @@
package com.zioinfo.esn.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class TenantVo {
private Long id;
private String tenantCode;
private String tenantName;
private String description;
private boolean active;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,20 @@
package com.zioinfo.esn.domain;
import lombok.Data;
import java.time.LocalDateTime;
@Data
public class WorkHistoryVo {
private Long id;
private String tenantCode;
private Long storeId;
private String storeName;
private String workType; // INSTALL, MAINTENANCE, FIRMWARE_UPDATE, REPLACEMENT, INSPECTION
private String workContent;
private String workerName;
private String status; // SCHEDULED, IN_PROGRESS, COMPLETED, CANCELLED
private String remarks;
private LocalDateTime startedAt;
private LocalDateTime completedAt;
private LocalDateTime createdAt;
}

View File

@ -0,0 +1,23 @@
package com.zioinfo.esn.mapper;
import com.zioinfo.esn.domain.AlarmVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface AlarmMapper {
List<AlarmVo> findAll(@Param("tenantCode") String tenantCode,
@Param("storeId") Long storeId,
@Param("severity") String severity,
@Param("status") String status);
AlarmVo findById(@Param("id") Long id);
int insert(AlarmVo alarm);
int update(AlarmVo alarm);
int resolve(@Param("id") Long id, @Param("resolvedBy") String resolvedBy,
@Param("resolution") String resolution);
int delete(@Param("id") Long id);
long countUnresolved(@Param("tenantCode") String tenantCode);
long countCritical(@Param("tenantCode") String tenantCode);
long countAll(@Param("tenantCode") String tenantCode);
}

View File

@ -0,0 +1,10 @@
package com.zioinfo.esn.mapper;
import com.zioinfo.esn.domain.DashboardVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
@Mapper
public interface DashboardMapper {
DashboardVo getSummary(@Param("tenantCode") String tenantCode);
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.esn.mapper;
import com.zioinfo.esn.domain.FirmwareVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface FirmwareMapper {
List<FirmwareVo> findAll(@Param("tenantCode") String tenantCode,
@Param("deviceType") String deviceType);
FirmwareVo findById(@Param("id") Long id);
FirmwareVo findLatest(@Param("deviceType") String deviceType,
@Param("tenantCode") String tenantCode);
int insert(FirmwareVo firmware);
int update(FirmwareVo firmware);
int clearLatest(@Param("deviceType") String deviceType, @Param("tenantCode") String tenantCode);
int delete(@Param("id") Long id);
}

View File

@ -0,0 +1,23 @@
package com.zioinfo.esn.mapper;
import com.zioinfo.esn.domain.HCoreVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface HCoreMapper {
List<HCoreVo> findAll(@Param("tenantCode") String tenantCode,
@Param("storeId") Long storeId,
@Param("deviceType") String deviceType,
@Param("status") String status);
HCoreVo findById(@Param("id") Long id);
HCoreVo findByDeviceId(@Param("deviceId") String deviceId);
int insert(HCoreVo hcore);
int update(HCoreVo hcore);
int updateStatus(@Param("id") Long id, @Param("status") String status);
int delete(@Param("id") Long id);
long countOnline(@Param("tenantCode") String tenantCode);
long countOffline(@Param("tenantCode") String tenantCode);
long countAll(@Param("tenantCode") String tenantCode);
}

View File

@ -0,0 +1,20 @@
package com.zioinfo.esn.mapper;
import com.zioinfo.esn.domain.PosCvtVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface PosCvtMapper {
List<PosCvtVo> findAll(@Param("tenantCode") String tenantCode,
@Param("storeId") Long storeId,
@Param("status") String status,
@Param("keyword") String keyword);
PosCvtVo findById(@Param("id") Long id);
int insert(PosCvtVo posCvt);
int updateStatus(@Param("id") Long id, @Param("status") String status,
@Param("errorMessage") String errorMessage);
long countPending(@Param("tenantCode") String tenantCode);
long countProcessedToday(@Param("tenantCode") String tenantCode);
}

View File

@ -0,0 +1,19 @@
package com.zioinfo.esn.mapper;
import com.zioinfo.esn.domain.ProductVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface ProductMapper {
List<ProductVo> findAll(@Param("tenantCode") String tenantCode,
@Param("storeId") Long storeId,
@Param("keyword") String keyword);
ProductVo findById(@Param("id") Long id);
ProductVo findByCode(@Param("productCode") String productCode,
@Param("storeId") Long storeId);
int insert(ProductVo product);
int update(ProductVo product);
int delete(@Param("id") Long id);
}

View File

@ -0,0 +1,15 @@
package com.zioinfo.esn.mapper;
import com.zioinfo.esn.domain.StoreGroupVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface StoreGroupMapper {
List<StoreGroupVo> findAll(@Param("tenantCode") String tenantCode);
StoreGroupVo findById(@Param("id") Long id);
int insert(StoreGroupVo group);
int update(StoreGroupVo group);
int delete(@Param("id") Long id);
}

View File

@ -0,0 +1,20 @@
package com.zioinfo.esn.mapper;
import com.zioinfo.esn.domain.StoreVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface StoreMapper {
List<StoreVo> findAll(@Param("tenantCode") String tenantCode,
@Param("storeGroupId") Long storeGroupId,
@Param("keyword") String keyword);
StoreVo findById(@Param("id") Long id);
StoreVo findByCode(@Param("storeCode") String storeCode, @Param("tenantCode") String tenantCode);
int insert(StoreVo store);
int update(StoreVo store);
int delete(@Param("id") Long id);
long countActive(@Param("tenantCode") String tenantCode);
long countAll(@Param("tenantCode") String tenantCode);
}

View File

@ -0,0 +1,16 @@
package com.zioinfo.esn.mapper;
import com.zioinfo.esn.domain.TemplateVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface TemplateMapper {
List<TemplateVo> findAll(@Param("tenantCode") String tenantCode,
@Param("templateType") String templateType);
TemplateVo findById(@Param("id") Long id);
int insert(TemplateVo template);
int update(TemplateVo template);
int delete(@Param("id") Long id);
}

View File

@ -0,0 +1,16 @@
package com.zioinfo.esn.mapper;
import com.zioinfo.esn.domain.TenantVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface TenantMapper {
List<TenantVo> findAll();
TenantVo findById(@Param("id") Long id);
TenantVo findByCode(@Param("tenantCode") String tenantCode);
int insert(TenantVo tenant);
int update(TenantVo tenant);
int delete(@Param("id") Long id);
}

View File

@ -0,0 +1,18 @@
package com.zioinfo.esn.mapper;
import com.zioinfo.esn.auth.EsnUser;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface UserMapper {
List<EsnUser> findAll(@Param("tenantCode") String tenantCode,
@Param("role") String role);
EsnUser findById(@Param("id") Long id);
EsnUser findByUsername(@Param("username") String username);
int insert(EsnUser user);
int update(EsnUser user);
int updatePassword(@Param("id") Long id, @Param("passwordHash") String passwordHash);
int delete(@Param("id") Long id);
}

View File

@ -0,0 +1,20 @@
package com.zioinfo.esn.mapper;
import com.zioinfo.esn.domain.WorkHistoryVo;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface WorkMapper {
List<WorkHistoryVo> findAll(@Param("tenantCode") String tenantCode,
@Param("storeId") Long storeId,
@Param("status") String status,
@Param("workType") String workType);
WorkHistoryVo findById(@Param("id") Long id);
int insert(WorkHistoryVo work);
int update(WorkHistoryVo work);
int delete(@Param("id") Long id);
long countToday(@Param("tenantCode") String tenantCode);
long countCompletedToday(@Param("tenantCode") String tenantCode);
}

View File

@ -0,0 +1,36 @@
package com.zioinfo.esn.service;
import com.zioinfo.esn.domain.AlarmVo;
import com.zioinfo.esn.mapper.AlarmMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class AlarmService {
private final AlarmMapper mapper;
public List<AlarmVo> list(String tenantCode, Long storeId, String severity, String status) {
return mapper.findAll(tenantCode, storeId, severity, status);
}
public AlarmVo get(Long id) { return mapper.findById(id); }
public AlarmVo create(AlarmVo v) {
mapper.insert(v);
return mapper.findById(v.getId());
}
public AlarmVo update(Long id, AlarmVo v) {
v.setId(id);
mapper.update(v);
return mapper.findById(id);
}
public void resolve(Long id, String resolvedBy, String resolution) {
mapper.resolve(id, resolvedBy, resolution);
}
public void delete(Long id) { mapper.delete(id); }
}

View File

@ -0,0 +1,38 @@
package com.zioinfo.esn.service;
import com.zioinfo.esn.domain.FirmwareVo;
import com.zioinfo.esn.mapper.FirmwareMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class FirmwareService {
private final FirmwareMapper mapper;
public List<FirmwareVo> list(String tenantCode, String deviceType) {
return mapper.findAll(tenantCode, deviceType);
}
public FirmwareVo get(Long id) { return mapper.findById(id); }
public FirmwareVo create(FirmwareVo v) {
if (v.isLatest()) {
mapper.clearLatest(v.getDeviceType(), v.getTenantCode());
}
mapper.insert(v);
return mapper.findById(v.getId());
}
public FirmwareVo update(Long id, FirmwareVo v) {
v.setId(id);
if (v.isLatest()) {
mapper.clearLatest(v.getDeviceType(), v.getTenantCode());
}
mapper.update(v);
return mapper.findById(id);
}
public void delete(Long id) { mapper.delete(id); }
}

View File

@ -0,0 +1,36 @@
package com.zioinfo.esn.service;
import com.zioinfo.esn.domain.HCoreVo;
import com.zioinfo.esn.mapper.HCoreMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class HCoreService {
private final HCoreMapper mapper;
public List<HCoreVo> list(String tenantCode, Long storeId, String deviceType, String status) {
return mapper.findAll(tenantCode, storeId, deviceType, status);
}
public HCoreVo get(Long id) { return mapper.findById(id); }
public HCoreVo create(HCoreVo v) {
mapper.insert(v);
return mapper.findById(v.getId());
}
public HCoreVo update(Long id, HCoreVo v) {
v.setId(id);
mapper.update(v);
return mapper.findById(id);
}
public void updateStatus(Long id, String status) {
mapper.updateStatus(id, status);
}
public void delete(Long id) { mapper.delete(id); }
}

View File

@ -0,0 +1,31 @@
package com.zioinfo.esn.service;
import com.zioinfo.esn.domain.PosCvtVo;
import com.zioinfo.esn.mapper.PosCvtMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class PosCvtService {
private final PosCvtMapper mapper;
public List<PosCvtVo> list(String tenantCode, Long storeId, String status, String keyword) {
return mapper.findAll(tenantCode, storeId, status, keyword);
}
public PosCvtVo get(Long id) { return mapper.findById(id); }
public void process(Long id) {
mapper.updateStatus(id, "PROCESSED", null);
}
public void markError(Long id, String errorMessage) {
mapper.updateStatus(id, "ERROR", errorMessage);
}
public void ignore(Long id) {
mapper.updateStatus(id, "IGNORED", null);
}
}

View File

@ -0,0 +1,32 @@
package com.zioinfo.esn.service;
import com.zioinfo.esn.domain.ProductVo;
import com.zioinfo.esn.mapper.ProductMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductMapper mapper;
public List<ProductVo> list(String tenantCode, Long storeId, String keyword) {
return mapper.findAll(tenantCode, storeId, keyword);
}
public ProductVo get(Long id) { return mapper.findById(id); }
public ProductVo create(ProductVo v) {
mapper.insert(v);
return mapper.findById(v.getId());
}
public ProductVo update(Long id, ProductVo v) {
v.setId(id);
mapper.update(v);
return mapper.findById(id);
}
public void delete(Long id) { mapper.delete(id); }
}

View File

@ -0,0 +1,29 @@
package com.zioinfo.esn.service;
import com.zioinfo.esn.domain.StoreGroupVo;
import com.zioinfo.esn.mapper.StoreGroupMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class StoreGroupService {
private final StoreGroupMapper mapper;
public List<StoreGroupVo> list(String tenantCode) { return mapper.findAll(tenantCode); }
public StoreGroupVo get(Long id) { return mapper.findById(id); }
public StoreGroupVo create(StoreGroupVo v) {
mapper.insert(v);
return mapper.findById(v.getId());
}
public StoreGroupVo update(Long id, StoreGroupVo v) {
v.setId(id);
mapper.update(v);
return mapper.findById(id);
}
public void delete(Long id) { mapper.delete(id); }
}

View File

@ -0,0 +1,32 @@
package com.zioinfo.esn.service;
import com.zioinfo.esn.domain.StoreVo;
import com.zioinfo.esn.mapper.StoreMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class StoreService {
private final StoreMapper mapper;
public List<StoreVo> list(String tenantCode, Long storeGroupId, String keyword) {
return mapper.findAll(tenantCode, storeGroupId, keyword);
}
public StoreVo get(Long id) { return mapper.findById(id); }
public StoreVo create(StoreVo v) {
mapper.insert(v);
return mapper.findById(v.getId());
}
public StoreVo update(Long id, StoreVo v) {
v.setId(id);
mapper.update(v);
return mapper.findById(id);
}
public void delete(Long id) { mapper.delete(id); }
}

View File

@ -0,0 +1,32 @@
package com.zioinfo.esn.service;
import com.zioinfo.esn.domain.TemplateVo;
import com.zioinfo.esn.mapper.TemplateMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class TemplateService {
private final TemplateMapper mapper;
public List<TemplateVo> list(String tenantCode, String templateType) {
return mapper.findAll(tenantCode, templateType);
}
public TemplateVo get(Long id) { return mapper.findById(id); }
public TemplateVo create(TemplateVo v) {
mapper.insert(v);
return mapper.findById(v.getId());
}
public TemplateVo update(Long id, TemplateVo v) {
v.setId(id);
mapper.update(v);
return mapper.findById(id);
}
public void delete(Long id) { mapper.delete(id); }
}

View File

@ -0,0 +1,29 @@
package com.zioinfo.esn.service;
import com.zioinfo.esn.domain.TenantVo;
import com.zioinfo.esn.mapper.TenantMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class TenantService {
private final TenantMapper mapper;
public List<TenantVo> list() { return mapper.findAll(); }
public TenantVo get(Long id) { return mapper.findById(id); }
public TenantVo create(TenantVo v) {
mapper.insert(v);
return mapper.findById(v.getId());
}
public TenantVo update(Long id, TenantVo v) {
v.setId(id);
mapper.update(v);
return mapper.findById(id);
}
public void delete(Long id) { mapper.delete(id); }
}

View File

@ -0,0 +1,39 @@
package com.zioinfo.esn.service;
import com.zioinfo.esn.auth.EsnUser;
import com.zioinfo.esn.mapper.UserMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class UserService {
private final UserMapper mapper;
private final PasswordEncoder passwordEncoder;
public List<EsnUser> list(String tenantCode, String role) {
return mapper.findAll(tenantCode, role);
}
public EsnUser get(Long id) { return mapper.findById(id); }
public EsnUser create(EsnUser v, String rawPassword) {
v.setPasswordHash(passwordEncoder.encode(rawPassword));
mapper.insert(v);
return mapper.findById(v.getId());
}
public EsnUser update(Long id, EsnUser v) {
v.setId(id);
mapper.update(v);
return mapper.findById(id);
}
public void changePassword(Long id, String rawPassword) {
mapper.updatePassword(id, passwordEncoder.encode(rawPassword));
}
public void delete(Long id) { mapper.delete(id); }
}

View File

@ -0,0 +1,32 @@
package com.zioinfo.esn.service;
import com.zioinfo.esn.domain.WorkHistoryVo;
import com.zioinfo.esn.mapper.WorkMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class WorkService {
private final WorkMapper mapper;
public List<WorkHistoryVo> list(String tenantCode, Long storeId, String status, String workType) {
return mapper.findAll(tenantCode, storeId, status, workType);
}
public WorkHistoryVo get(Long id) { return mapper.findById(id); }
public WorkHistoryVo create(WorkHistoryVo v) {
mapper.insert(v);
return mapper.findById(v.getId());
}
public WorkHistoryVo update(Long id, WorkHistoryVo v) {
v.setId(id);
mapper.update(v);
return mapper.findById(id);
}
public void delete(Long id) { mapper.delete(id); }
}

View File

@ -0,0 +1,48 @@
server:
port: 8015
spring:
application:
name: zioinfo-esn
datasource:
url: ${DB_URL:jdbc:postgresql://localhost:5432/esn_db}
username: ${DB_USER:esn_user}
password: ${DB_PASS:esn_pass2026}
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 3
connection-timeout: 30000
web:
resources:
static-locations: classpath:/static/
mvc:
throw-exception-if-no-handler-found: true
mybatis:
mapper-locations: classpath:mapper/*.xml
configuration:
map-underscore-to-camel-case: true
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
type-aliases-package: com.zioinfo.esn.domain
guardia:
ollama-url: ${OLLAMA_URL:http://localhost:11434}
ollama-text-model: ${OLLAMA_TEXT_MODEL:llama3}
itsm-url: ${ITSM_URL:http://localhost:9001}
jwt:
secret: ${JWT_SECRET:ZioInfoEsnJwtSecret2026VeryLongSecretKeyForHS256AlgorithmAtLeast256Bits}
expiration: 86400000
management:
endpoints:
web:
exposure:
include: health
endpoint:
health:
show-details: never
logging:
level:
com.zioinfo.esn: DEBUG
org.mybatis: WARN

View File

@ -0,0 +1,262 @@
-- ============================================================================
-- zioinfo-esn ESL 통합 플랫폼 — PostgreSQL 스키마 (esn_db)
-- 레거시 Spring Boot 1.5 / Java 8 6개 프로젝트 → 단일 플랫폼
-- 멀티테넌트: LGINNOTEK / LGIT / EMART / ZIOINFO
-- ============================================================================
-- ── 테넌트 ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS esn_tenant (
id BIGSERIAL PRIMARY KEY,
tenant_code VARCHAR(20) UNIQUE NOT NULL,
tenant_name VARCHAR(200) NOT NULL,
description TEXT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW()
);
INSERT INTO esn_tenant (tenant_code, tenant_name, description) VALUES
('LGINNOTEK', 'LG 이노텍', 'LG 이노텍 ESL 관리'),
('LGIT', 'LG IT', 'LG IT 서비스 ESL'),
('EMART', '이마트', '이마트 전자가격표 시스템'),
('ZIOINFO', '지오정보기술', '지오정보기술 자체 ESL')
ON CONFLICT (tenant_code) DO NOTHING;
-- ── 매장 그룹 ────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS esn_store_group (
id BIGSERIAL PRIMARY KEY,
tenant_code VARCHAR(20) NOT NULL REFERENCES esn_tenant(tenant_code),
group_code VARCHAR(50) NOT NULL,
group_name VARCHAR(200) NOT NULL,
description TEXT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE (tenant_code, group_code)
);
INSERT INTO esn_store_group (tenant_code, group_code, group_name) VALUES
('LGINNOTEK', 'GRP-LGI-01', 'LG이노텍 구미공장'),
('LGIT', 'GRP-LGIT-01','LG IT 본사'),
('EMART', 'GRP-EM-SEOUL', '이마트 서울권'),
('EMART', 'GRP-EM-GYEONG','이마트 경기권'),
('ZIOINFO', 'GRP-ZIO-01', '지오정보기술 본사')
ON CONFLICT (tenant_code, group_code) DO NOTHING;
-- ── 매장 ─────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS esn_store (
id BIGSERIAL PRIMARY KEY,
tenant_code VARCHAR(20) NOT NULL REFERENCES esn_tenant(tenant_code),
store_code VARCHAR(50) NOT NULL,
store_name VARCHAR(200) NOT NULL,
address VARCHAR(500),
phone VARCHAR(50),
store_group_id BIGINT REFERENCES esn_store_group(id),
manager_name VARCHAR(100),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE (tenant_code, store_code)
);
INSERT INTO esn_store (tenant_code, store_code, store_name, address, manager_name, store_group_id)
SELECT 'LGINNOTEK', 'LGI-001', 'LG이노텍 구미 1라인', '경북 구미시 공단동 1', '김철수', g.id
FROM esn_store_group g WHERE g.group_code = 'GRP-LGI-01' AND g.tenant_code = 'LGINNOTEK'
ON CONFLICT (tenant_code, store_code) DO NOTHING;
INSERT INTO esn_store (tenant_code, store_code, store_name, address, manager_name, store_group_id)
SELECT 'EMART', 'EM-001', '이마트 강남점', '서울 강남구 삼성동', '박영희', g.id
FROM esn_store_group g WHERE g.group_code = 'GRP-EM-SEOUL' AND g.tenant_code = 'EMART'
ON CONFLICT (tenant_code, store_code) DO NOTHING;
INSERT INTO esn_store (tenant_code, store_code, store_name, address, manager_name, store_group_id)
SELECT 'EMART', 'EM-002', '이마트 수원점', '경기 수원시 팔달구', '이민수', g.id
FROM esn_store_group g WHERE g.group_code = 'GRP-EM-GYEONG' AND g.tenant_code = 'EMART'
ON CONFLICT (tenant_code, store_code) DO NOTHING;
-- ── ESL 템플릿 ───────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS esn_template (
id BIGSERIAL PRIMARY KEY,
tenant_code VARCHAR(20) NOT NULL REFERENCES esn_tenant(tenant_code),
template_code VARCHAR(50) NOT NULL,
template_name VARCHAR(200) NOT NULL,
template_type VARCHAR(50) DEFAULT 'PRICE', -- PRICE, INFO, PROMO, CUSTOM
width INTEGER,
height INTEGER,
description TEXT,
layout_json TEXT,
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE (tenant_code, template_code)
);
INSERT INTO esn_template (tenant_code, template_code, template_name, template_type, width, height, description) VALUES
('EMART', 'EM-PRICE-STD', '이마트 표준 가격표', 'PRICE', 152, 76, '표준 2.9인치 ESL 템플릿'),
('EMART', 'EM-PROMO', '이마트 프로모션', 'PROMO', 296, 128, '5인치 프로모션 표시'),
('LGINNOTEK', 'LGI-INFO', 'LG이노텍 부품 정보', 'INFO', 152, 76, '부품 코드·수량 표시')
ON CONFLICT (tenant_code, template_code) DO NOTHING;
-- ── POS 가격 변환 ─────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS esn_pos_cvt (
id BIGSERIAL PRIMARY KEY,
tenant_code VARCHAR(20) NOT NULL REFERENCES esn_tenant(tenant_code),
store_id BIGINT REFERENCES esn_store(id),
pos_code VARCHAR(100),
product_code VARCHAR(100) NOT NULL,
product_name VARCHAR(300),
price NUMERIC(12,2),
sale_price NUMERIC(12,2),
currency VARCHAR(10) DEFAULT 'KRW',
status VARCHAR(20) DEFAULT 'PENDING', -- PENDING, PROCESSED, ERROR, IGNORED
error_message TEXT,
processed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_pos_cvt_tenant ON esn_pos_cvt(tenant_code, status);
CREATE INDEX IF NOT EXISTS idx_pos_cvt_store ON esn_pos_cvt(store_id, status);
-- ── 알람 ─────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS esn_alarm (
id BIGSERIAL PRIMARY KEY,
tenant_code VARCHAR(20) NOT NULL REFERENCES esn_tenant(tenant_code),
store_id BIGINT REFERENCES esn_store(id),
alarm_type VARCHAR(50) NOT NULL, -- DEVICE, NETWORK, BATTERY, FIRMWARE, SYSTEM
alarm_code VARCHAR(100),
severity VARCHAR(20) NOT NULL, -- CRITICAL, HIGH, MEDIUM, LOW
message TEXT,
status VARCHAR(20) DEFAULT 'OPEN', -- OPEN, ACKNOWLEDGED, RESOLVED
resolved_by VARCHAR(100),
resolution TEXT,
resolved_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_alarm_tenant ON esn_alarm(tenant_code, status);
CREATE INDEX IF NOT EXISTS idx_alarm_severity ON esn_alarm(severity, status);
-- 샘플 알람
INSERT INTO esn_alarm (tenant_code, store_id, alarm_type, alarm_code, severity, message, status)
SELECT 'EMART', s.id, 'DEVICE', 'DEV-OFFLINE', 'HIGH', 'ESL 장치 오프라인 감지 — 매장 내 점검 필요', 'OPEN'
FROM esn_store s WHERE s.store_code = 'EM-001' LIMIT 1;
INSERT INTO esn_alarm (tenant_code, store_id, alarm_type, alarm_code, severity, message, status)
SELECT 'EMART', s.id, 'BATTERY', 'BAT-LOW', 'MEDIUM', 'ESL 배터리 잔량 15% 미만 — 배터리 교체 예정', 'OPEN'
FROM esn_store s WHERE s.store_code = 'EM-002' LIMIT 1;
-- ── HCore 장치 (게이트웨이/허브/ESL) ─────────────────────────────────────────
CREATE TABLE IF NOT EXISTS esn_hcore_device (
id BIGSERIAL PRIMARY KEY,
tenant_code VARCHAR(20) NOT NULL REFERENCES esn_tenant(tenant_code),
store_id BIGINT REFERENCES esn_store(id),
device_type VARCHAR(50) NOT NULL, -- GATEWAY, HUB, ESL_DEVICE
device_id VARCHAR(100) NOT NULL,
ip_address VARCHAR(50),
mac_address VARCHAR(50),
firmware_version VARCHAR(50),
status VARCHAR(20) DEFAULT 'ONLINE', -- ONLINE, OFFLINE, ERROR, UPDATING
battery_level INTEGER,
signal_strength VARCHAR(20),
last_seen_at TIMESTAMP DEFAULT NOW(),
created_at TIMESTAMP DEFAULT NOW(),
UNIQUE (device_id)
);
CREATE INDEX IF NOT EXISTS idx_hcore_tenant ON esn_hcore_device(tenant_code, status);
CREATE INDEX IF NOT EXISTS idx_hcore_store ON esn_hcore_device(store_id, status);
-- 샘플 HCore 장치
INSERT INTO esn_hcore_device (tenant_code, store_id, device_type, device_id, ip_address, firmware_version, status)
SELECT 'EMART', s.id, 'GATEWAY', 'GW-EM-001-001', '192.168.1.1', '2.1.5', 'ONLINE'
FROM esn_store s WHERE s.store_code = 'EM-001' LIMIT 1
ON CONFLICT (device_id) DO NOTHING;
INSERT INTO esn_hcore_device (tenant_code, store_id, device_type, device_id, ip_address, firmware_version, status, battery_level)
SELECT 'EMART', s.id, 'ESL_DEVICE', 'ESL-EM-001-0001', NULL, '1.8.2', 'ONLINE', 85
FROM esn_store s WHERE s.store_code = 'EM-001' LIMIT 1
ON CONFLICT (device_id) DO NOTHING;
INSERT INTO esn_hcore_device (tenant_code, store_id, device_type, device_id, ip_address, firmware_version, status, battery_level)
SELECT 'EMART', s.id, 'ESL_DEVICE', 'ESL-EM-001-0002', NULL, '1.8.2', 'OFFLINE', 8
FROM esn_store s WHERE s.store_code = 'EM-001' LIMIT 1
ON CONFLICT (device_id) DO NOTHING;
-- ── 작업 이력 ────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS esn_work_history (
id BIGSERIAL PRIMARY KEY,
tenant_code VARCHAR(20) NOT NULL REFERENCES esn_tenant(tenant_code),
store_id BIGINT REFERENCES esn_store(id),
work_type VARCHAR(50) NOT NULL, -- INSTALL, MAINTENANCE, FIRMWARE_UPDATE, REPLACEMENT, INSPECTION
work_content TEXT,
worker_name VARCHAR(100),
status VARCHAR(20) DEFAULT 'SCHEDULED', -- SCHEDULED, IN_PROGRESS, COMPLETED, CANCELLED
remarks TEXT,
started_at TIMESTAMP,
completed_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_work_tenant ON esn_work_history(tenant_code, status);
-- ── 펌웨어 ───────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS esn_firmware (
id BIGSERIAL PRIMARY KEY,
tenant_code VARCHAR(20) NOT NULL REFERENCES esn_tenant(tenant_code),
firmware_version VARCHAR(50) NOT NULL,
device_type VARCHAR(50) NOT NULL,
file_name VARCHAR(300),
file_path VARCHAR(500),
file_size BIGINT,
checksum VARCHAR(200),
description TEXT,
is_latest BOOLEAN DEFAULT FALSE,
created_at TIMESTAMP DEFAULT NOW()
);
INSERT INTO esn_firmware (tenant_code, firmware_version, device_type, file_name, description, is_latest) VALUES
('EMART', '2.1.5', 'GATEWAY', 'gw_v2.1.5.bin', 'EMART 게이트웨이 최신 펌웨어', true),
('EMART', '1.8.2', 'ESL_DEVICE', 'esl_v1.8.2.bin', 'EMART ESL 디바이스 최신 펌웨어', true)
ON CONFLICT DO NOTHING;
-- ── 사용자 ───────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS esn_user (
id BIGSERIAL PRIMARY KEY,
tenant_code VARCHAR(20) REFERENCES esn_tenant(tenant_code),
username VARCHAR(200) UNIQUE NOT NULL,
password_hash VARCHAR(500) NOT NULL,
role VARCHAR(50) DEFAULT 'USER', -- ADMIN, MANAGER, USER
email VARCHAR(300),
phone VARCHAR(50),
is_active BOOLEAN DEFAULT TRUE,
last_login_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW()
);
-- 기본 관리자 (비밀번호: admin123 / BCrypt)
INSERT INTO esn_user (tenant_code, username, password_hash, role)
VALUES (NULL, 'admin', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LnCWh7Vz.Xm', 'ADMIN')
ON CONFLICT (username) DO NOTHING;
INSERT INTO esn_user (tenant_code, username, password_hash, role)
VALUES ('EMART', 'emart_manager', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LnCWh7Vz.Xm', 'MANAGER')
ON CONFLICT (username) DO NOTHING;
INSERT INTO esn_user (tenant_code, username, password_hash, role)
VALUES ('LGINNOTEK', 'lgi_manager', '$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LnCWh7Vz.Xm', 'MANAGER')
ON CONFLICT (username) DO NOTHING;
-- ── 상품 ─────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS esn_product (
id BIGSERIAL PRIMARY KEY,
tenant_code VARCHAR(20) NOT NULL REFERENCES esn_tenant(tenant_code),
store_id BIGINT REFERENCES esn_store(id),
product_code VARCHAR(100) NOT NULL,
product_name VARCHAR(300) NOT NULL,
category VARCHAR(100),
price NUMERIC(12,2),
sale_price NUMERIC(12,2),
currency VARCHAR(10) DEFAULT 'KRW',
is_active BOOLEAN DEFAULT TRUE,
updated_at TIMESTAMP DEFAULT NOW(),
created_at TIMESTAMP DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_product_store ON esn_product(store_id, product_code);
-- 샘플 상품
INSERT INTO esn_product (tenant_code, store_id, product_code, product_name, category, price, sale_price)
SELECT 'EMART', s.id, 'P-001', '신라면', '라면/면류', 850, 800
FROM esn_store s WHERE s.store_code = 'EM-001' LIMIT 1
ON CONFLICT DO NOTHING;

View File

@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.esn.mapper.AlarmMapper">
<resultMap id="alarmMap" type="com.zioinfo.esn.domain.AlarmVo">
<id property="id" column="id"/>
<result property="tenantCode" column="tenant_code"/>
<result property="storeId" column="store_id"/>
<result property="storeName" column="store_name"/>
<result property="alarmType" column="alarm_type"/>
<result property="alarmCode" column="alarm_code"/>
<result property="severity" column="severity"/>
<result property="message" column="message"/>
<result property="status" column="status"/>
<result property="resolvedBy" column="resolved_by"/>
<result property="resolution" column="resolution"/>
<result property="resolvedAt" column="resolved_at"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findAll" resultMap="alarmMap">
SELECT a.id, a.tenant_code, a.store_id, s.store_name, a.alarm_type, a.alarm_code,
a.severity, a.message, a.status, a.resolved_by, a.resolution, a.resolved_at, a.created_at
FROM esn_alarm a
LEFT JOIN esn_store s ON a.store_id = s.id
<where>
<if test="tenantCode != null and tenantCode != ''">AND a.tenant_code = #{tenantCode}</if>
<if test="storeId != null">AND a.store_id = #{storeId}</if>
<if test="severity != null and severity != ''">AND a.severity = #{severity}</if>
<if test="status != null and status != ''">AND a.status = #{status}</if>
</where>
ORDER BY a.created_at DESC LIMIT 200
</select>
<select id="findById" resultMap="alarmMap">
SELECT a.id, a.tenant_code, a.store_id, s.store_name, a.alarm_type, a.alarm_code,
a.severity, a.message, a.status, a.resolved_by, a.resolution, a.resolved_at, a.created_at
FROM esn_alarm a
LEFT JOIN esn_store s ON a.store_id = s.id
WHERE a.id = #{id}
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO esn_alarm (tenant_code, store_id, alarm_type, alarm_code, severity, message, status)
VALUES (#{tenantCode}, #{storeId}, #{alarmType}, #{alarmCode}, #{severity}, #{message}, 'OPEN')
</insert>
<update id="update">
UPDATE esn_alarm SET
alarm_type = #{alarmType},
severity = #{severity},
message = #{message},
status = #{status}
WHERE id = #{id}
</update>
<update id="resolve">
UPDATE esn_alarm SET
status = 'RESOLVED',
resolved_by = #{resolvedBy},
resolution = #{resolution},
resolved_at = NOW()
WHERE id = #{id}
</update>
<delete id="delete">DELETE FROM esn_alarm WHERE id = #{id}</delete>
<select id="countUnresolved" resultType="long">
SELECT COUNT(*) FROM esn_alarm WHERE status != 'RESOLVED'
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
</select>
<select id="countCritical" resultType="long">
SELECT COUNT(*) FROM esn_alarm WHERE severity = 'CRITICAL' AND status != 'RESOLVED'
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
</select>
<select id="countAll" resultType="long">
SELECT COUNT(*) FROM esn_alarm
<where>
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
</where>
</select>
</mapper>

View File

@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.esn.mapper.DashboardMapper">
<select id="getSummary" resultType="com.zioinfo.esn.domain.DashboardVo">
SELECT
(SELECT COUNT(*) FROM esn_store
WHERE 1=1 <if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
) AS total_stores,
(SELECT COUNT(*) FROM esn_store WHERE is_active = true
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
) AS active_stores,
(SELECT COUNT(*) FROM esn_alarm
WHERE 1=1 <if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
) AS total_alarms,
(SELECT COUNT(*) FROM esn_alarm WHERE status != 'RESOLVED'
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
) AS unresolved_alarms,
(SELECT COUNT(*) FROM esn_alarm WHERE severity = 'CRITICAL' AND status != 'RESOLVED'
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
) AS critical_alarms,
(SELECT COUNT(*) FROM esn_hcore_device
WHERE 1=1 <if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
) AS total_devices,
(SELECT COUNT(*) FROM esn_hcore_device WHERE status = 'ONLINE'
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
) AS online_devices,
(SELECT COUNT(*) FROM esn_hcore_device WHERE status = 'OFFLINE'
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
) AS offline_devices,
(SELECT COUNT(*) FROM esn_work_history WHERE created_at::date = CURRENT_DATE
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
) AS total_work_today,
(SELECT COUNT(*) FROM esn_work_history WHERE status = 'COMPLETED' AND completed_at::date = CURRENT_DATE
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
) AS completed_work_today,
(SELECT COUNT(*) FROM esn_pos_cvt WHERE status = 'PENDING'
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
) AS pending_pos_cvt,
(SELECT COUNT(*) FROM esn_pos_cvt WHERE status = 'PROCESSED' AND processed_at::date = CURRENT_DATE
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
) AS processed_pos_cvt_today
</select>
</mapper>

View File

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.esn.mapper.FirmwareMapper">
<resultMap id="firmwareMap" type="com.zioinfo.esn.domain.FirmwareVo">
<id property="id" column="id"/>
<result property="tenantCode" column="tenant_code"/>
<result property="firmwareVersion" column="firmware_version"/>
<result property="deviceType" column="device_type"/>
<result property="fileName" column="file_name"/>
<result property="filePath" column="file_path"/>
<result property="fileSize" column="file_size"/>
<result property="checksum" column="checksum"/>
<result property="description" column="description"/>
<result property="latest" column="is_latest"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findAll" resultMap="firmwareMap">
SELECT id, tenant_code, firmware_version, device_type, file_name, file_path,
file_size, checksum, description, is_latest, created_at
FROM esn_firmware
<where>
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
<if test="deviceType != null and deviceType != ''">AND device_type = #{deviceType}</if>
</where>
ORDER BY created_at DESC
</select>
<select id="findById" resultMap="firmwareMap">
SELECT id, tenant_code, firmware_version, device_type, file_name, file_path,
file_size, checksum, description, is_latest, created_at
FROM esn_firmware WHERE id = #{id}
</select>
<select id="findLatest" resultMap="firmwareMap">
SELECT id, tenant_code, firmware_version, device_type, file_name, file_path,
file_size, checksum, description, is_latest, created_at
FROM esn_firmware
WHERE device_type = #{deviceType} AND is_latest = true
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
LIMIT 1
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO esn_firmware (tenant_code, firmware_version, device_type, file_name, file_path,
file_size, checksum, description, is_latest)
VALUES (#{tenantCode}, #{firmwareVersion}, #{deviceType}, #{fileName}, #{filePath},
#{fileSize}, #{checksum}, #{description}, #{latest})
</insert>
<update id="update">
UPDATE esn_firmware SET
firmware_version = #{firmwareVersion},
description = #{description},
is_latest = #{latest}
WHERE id = #{id}
</update>
<update id="clearLatest">
UPDATE esn_firmware SET is_latest = false
WHERE device_type = #{deviceType}
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
</update>
<delete id="delete">DELETE FROM esn_firmware WHERE id = #{id}</delete>
</mapper>

View File

@ -0,0 +1,99 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.esn.mapper.HCoreMapper">
<resultMap id="hcoreMap" type="com.zioinfo.esn.domain.HCoreVo">
<id property="id" column="id"/>
<result property="tenantCode" column="tenant_code"/>
<result property="storeId" column="store_id"/>
<result property="storeName" column="store_name"/>
<result property="deviceType" column="device_type"/>
<result property="deviceId" column="device_id"/>
<result property="ipAddress" column="ip_address"/>
<result property="macAddress" column="mac_address"/>
<result property="firmwareVersion" column="firmware_version"/>
<result property="status" column="status"/>
<result property="batteryLevel" column="battery_level"/>
<result property="signalStrength" column="signal_strength"/>
<result property="lastSeenAt" column="last_seen_at"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findAll" resultMap="hcoreMap">
SELECT h.id, h.tenant_code, h.store_id, s.store_name, h.device_type, h.device_id,
h.ip_address, h.mac_address, h.firmware_version, h.status, h.battery_level,
h.signal_strength, h.last_seen_at, h.created_at
FROM esn_hcore_device h
LEFT JOIN esn_store s ON h.store_id = s.id
<where>
<if test="tenantCode != null and tenantCode != ''">AND h.tenant_code = #{tenantCode}</if>
<if test="storeId != null">AND h.store_id = #{storeId}</if>
<if test="deviceType != null and deviceType != ''">AND h.device_type = #{deviceType}</if>
<if test="status != null and status != ''">AND h.status = #{status}</if>
</where>
ORDER BY h.device_id
</select>
<select id="findById" resultMap="hcoreMap">
SELECT h.id, h.tenant_code, h.store_id, s.store_name, h.device_type, h.device_id,
h.ip_address, h.mac_address, h.firmware_version, h.status, h.battery_level,
h.signal_strength, h.last_seen_at, h.created_at
FROM esn_hcore_device h
LEFT JOIN esn_store s ON h.store_id = s.id
WHERE h.id = #{id}
</select>
<select id="findByDeviceId" resultMap="hcoreMap">
SELECT h.id, h.tenant_code, h.store_id, s.store_name, h.device_type, h.device_id,
h.ip_address, h.mac_address, h.firmware_version, h.status, h.battery_level,
h.signal_strength, h.last_seen_at, h.created_at
FROM esn_hcore_device h
LEFT JOIN esn_store s ON h.store_id = s.id
WHERE h.device_id = #{deviceId}
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO esn_hcore_device (tenant_code, store_id, device_type, device_id, ip_address,
mac_address, firmware_version, status)
VALUES (#{tenantCode}, #{storeId}, #{deviceType}, #{deviceId}, #{ipAddress},
#{macAddress}, #{firmwareVersion}, #{status})
</insert>
<update id="update">
UPDATE esn_hcore_device SET
ip_address = #{ipAddress},
mac_address = #{macAddress},
firmware_version = #{firmwareVersion},
status = #{status},
battery_level = #{batteryLevel},
signal_strength = #{signalStrength},
last_seen_at = NOW()
WHERE id = #{id}
</update>
<update id="updateStatus">
UPDATE esn_hcore_device SET status = #{status}, last_seen_at = NOW()
WHERE id = #{id}
</update>
<delete id="delete">DELETE FROM esn_hcore_device WHERE id = #{id}</delete>
<select id="countOnline" resultType="long">
SELECT COUNT(*) FROM esn_hcore_device WHERE status = 'ONLINE'
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
</select>
<select id="countOffline" resultType="long">
SELECT COUNT(*) FROM esn_hcore_device WHERE status = 'OFFLINE'
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
</select>
<select id="countAll" resultType="long">
SELECT COUNT(*) FROM esn_hcore_device
<where>
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
</where>
</select>
</mapper>

View File

@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.esn.mapper.PosCvtMapper">
<resultMap id="posMap" type="com.zioinfo.esn.domain.PosCvtVo">
<id property="id" column="id"/>
<result property="tenantCode" column="tenant_code"/>
<result property="storeId" column="store_id"/>
<result property="storeName" column="store_name"/>
<result property="posCode" column="pos_code"/>
<result property="productCode" column="product_code"/>
<result property="productName" column="product_name"/>
<result property="price" column="price"/>
<result property="salePrice" column="sale_price"/>
<result property="currency" column="currency"/>
<result property="status" column="status"/>
<result property="errorMessage" column="error_message"/>
<result property="processedAt" column="processed_at"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findAll" resultMap="posMap">
SELECT p.id, p.tenant_code, p.store_id, s.store_name, p.pos_code,
p.product_code, p.product_name, p.price, p.sale_price, p.currency,
p.status, p.error_message, p.processed_at, p.created_at
FROM esn_pos_cvt p
LEFT JOIN esn_store s ON p.store_id = s.id
<where>
<if test="tenantCode != null and tenantCode != ''">AND p.tenant_code = #{tenantCode}</if>
<if test="storeId != null">AND p.store_id = #{storeId}</if>
<if test="status != null and status != ''">AND p.status = #{status}</if>
<if test="keyword != null and keyword != ''">
AND (p.product_code ILIKE '%' || #{keyword} || '%' OR p.product_name ILIKE '%' || #{keyword} || '%')
</if>
</where>
ORDER BY p.created_at DESC LIMIT 500
</select>
<select id="findById" resultMap="posMap">
SELECT p.id, p.tenant_code, p.store_id, s.store_name, p.pos_code,
p.product_code, p.product_name, p.price, p.sale_price, p.currency,
p.status, p.error_message, p.processed_at, p.created_at
FROM esn_pos_cvt p
LEFT JOIN esn_store s ON p.store_id = s.id
WHERE p.id = #{id}
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO esn_pos_cvt (tenant_code, store_id, pos_code, product_code, product_name,
price, sale_price, currency, status)
VALUES (#{tenantCode}, #{storeId}, #{posCode}, #{productCode}, #{productName},
#{price}, #{salePrice}, #{currency}, 'PENDING')
</insert>
<update id="updateStatus">
UPDATE esn_pos_cvt SET
status = #{status},
error_message = #{errorMessage},
processed_at = CASE WHEN #{status} = 'PROCESSED' THEN NOW() ELSE processed_at END
WHERE id = #{id}
</update>
<select id="countPending" resultType="long">
SELECT COUNT(*) FROM esn_pos_cvt WHERE status = 'PENDING'
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
</select>
<select id="countProcessedToday" resultType="long">
SELECT COUNT(*) FROM esn_pos_cvt
WHERE status = 'PROCESSED' AND processed_at::date = CURRENT_DATE
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
</select>
</mapper>

View File

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.esn.mapper.ProductMapper">
<resultMap id="productMap" type="com.zioinfo.esn.domain.ProductVo">
<id property="id" column="id"/>
<result property="tenantCode" column="tenant_code"/>
<result property="storeId" column="store_id"/>
<result property="storeName" column="store_name"/>
<result property="productCode" column="product_code"/>
<result property="productName" column="product_name"/>
<result property="category" column="category"/>
<result property="price" column="price"/>
<result property="salePrice" column="sale_price"/>
<result property="currency" column="currency"/>
<result property="active" column="is_active"/>
<result property="updatedAt" column="updated_at"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findAll" resultMap="productMap">
SELECT p.id, p.tenant_code, p.store_id, s.store_name, p.product_code, p.product_name,
p.category, p.price, p.sale_price, p.currency, p.is_active, p.updated_at, p.created_at
FROM esn_product p
LEFT JOIN esn_store s ON p.store_id = s.id
<where>
<if test="tenantCode != null and tenantCode != ''">AND p.tenant_code = #{tenantCode}</if>
<if test="storeId != null">AND p.store_id = #{storeId}</if>
<if test="keyword != null and keyword != ''">
AND (p.product_code ILIKE '%' || #{keyword} || '%' OR p.product_name ILIKE '%' || #{keyword} || '%')
</if>
</where>
ORDER BY p.product_code LIMIT 500
</select>
<select id="findById" resultMap="productMap">
SELECT p.id, p.tenant_code, p.store_id, s.store_name, p.product_code, p.product_name,
p.category, p.price, p.sale_price, p.currency, p.is_active, p.updated_at, p.created_at
FROM esn_product p
LEFT JOIN esn_store s ON p.store_id = s.id
WHERE p.id = #{id}
</select>
<select id="findByCode" resultMap="productMap">
SELECT p.id, p.tenant_code, p.store_id, s.store_name, p.product_code, p.product_name,
p.category, p.price, p.sale_price, p.currency, p.is_active, p.updated_at, p.created_at
FROM esn_product p
LEFT JOIN esn_store s ON p.store_id = s.id
WHERE p.product_code = #{productCode} AND p.store_id = #{storeId}
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO esn_product (tenant_code, store_id, product_code, product_name,
category, price, sale_price, currency, is_active)
VALUES (#{tenantCode}, #{storeId}, #{productCode}, #{productName},
#{category}, #{price}, #{salePrice}, #{currency}, #{active})
</insert>
<update id="update">
UPDATE esn_product SET
product_name = #{productName},
category = #{category},
price = #{price},
sale_price = #{salePrice},
currency = #{currency},
is_active = #{active},
updated_at = NOW()
WHERE id = #{id}
</update>
<delete id="delete">DELETE FROM esn_product WHERE id = #{id}</delete>
</mapper>

View File

@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.esn.mapper.StoreGroupMapper">
<resultMap id="groupMap" type="com.zioinfo.esn.domain.StoreGroupVo">
<id property="id" column="id"/>
<result property="tenantCode" column="tenant_code"/>
<result property="groupCode" column="group_code"/>
<result property="groupName" column="group_name"/>
<result property="description" column="description"/>
<result property="active" column="is_active"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findAll" resultMap="groupMap">
SELECT id, tenant_code, group_code, group_name, description, is_active, created_at
FROM esn_store_group
<where>
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
</where>
ORDER BY group_code
</select>
<select id="findById" resultMap="groupMap">
SELECT id, tenant_code, group_code, group_name, description, is_active, created_at
FROM esn_store_group WHERE id = #{id}
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO esn_store_group (tenant_code, group_code, group_name, description, is_active)
VALUES (#{tenantCode}, #{groupCode}, #{groupName}, #{description}, #{active})
</insert>
<update id="update">
UPDATE esn_store_group SET
group_name = #{groupName},
description = #{description},
is_active = #{active}
WHERE id = #{id}
</update>
<delete id="delete">DELETE FROM esn_store_group WHERE id = #{id}</delete>
</mapper>

View File

@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.esn.mapper.StoreMapper">
<resultMap id="storeMap" type="com.zioinfo.esn.domain.StoreVo">
<id property="id" column="id"/>
<result property="tenantCode" column="tenant_code"/>
<result property="storeCode" column="store_code"/>
<result property="storeName" column="store_name"/>
<result property="address" column="address"/>
<result property="phone" column="phone"/>
<result property="storeGroupId" column="store_group_id"/>
<result property="storeGroupName" column="group_name"/>
<result property="managerName" column="manager_name"/>
<result property="active" column="is_active"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findAll" resultMap="storeMap">
SELECT s.id, s.tenant_code, s.store_code, s.store_name, s.address, s.phone,
s.store_group_id, g.group_name, s.manager_name, s.is_active, s.created_at
FROM esn_store s
LEFT JOIN esn_store_group g ON s.store_group_id = g.id
<where>
<if test="tenantCode != null and tenantCode != ''">AND s.tenant_code = #{tenantCode}</if>
<if test="storeGroupId != null">AND s.store_group_id = #{storeGroupId}</if>
<if test="keyword != null and keyword != ''">
AND (s.store_name ILIKE '%' || #{keyword} || '%' OR s.store_code ILIKE '%' || #{keyword} || '%')
</if>
</where>
ORDER BY s.store_code
</select>
<select id="findById" resultMap="storeMap">
SELECT s.id, s.tenant_code, s.store_code, s.store_name, s.address, s.phone,
s.store_group_id, g.group_name, s.manager_name, s.is_active, s.created_at
FROM esn_store s
LEFT JOIN esn_store_group g ON s.store_group_id = g.id
WHERE s.id = #{id}
</select>
<select id="findByCode" resultMap="storeMap">
SELECT s.id, s.tenant_code, s.store_code, s.store_name, s.address, s.phone,
s.store_group_id, g.group_name, s.manager_name, s.is_active, s.created_at
FROM esn_store s
LEFT JOIN esn_store_group g ON s.store_group_id = g.id
WHERE s.store_code = #{storeCode} AND s.tenant_code = #{tenantCode}
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO esn_store (tenant_code, store_code, store_name, address, phone,
store_group_id, manager_name, is_active)
VALUES (#{tenantCode}, #{storeCode}, #{storeName}, #{address}, #{phone},
#{storeGroupId}, #{managerName}, #{active})
</insert>
<update id="update">
UPDATE esn_store SET
store_name = #{storeName},
address = #{address},
phone = #{phone},
store_group_id = #{storeGroupId},
manager_name = #{managerName},
is_active = #{active}
WHERE id = #{id}
</update>
<delete id="delete">DELETE FROM esn_store WHERE id = #{id}</delete>
<select id="countActive" resultType="long">
SELECT COUNT(*) FROM esn_store
WHERE is_active = true
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
</select>
<select id="countAll" resultType="long">
SELECT COUNT(*) FROM esn_store
<where>
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
</where>
</select>
</mapper>

View File

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.esn.mapper.TemplateMapper">
<resultMap id="templateMap" type="com.zioinfo.esn.domain.TemplateVo">
<id property="id" column="id"/>
<result property="tenantCode" column="tenant_code"/>
<result property="templateCode" column="template_code"/>
<result property="templateName" column="template_name"/>
<result property="templateType" column="template_type"/>
<result property="width" column="width"/>
<result property="height" column="height"/>
<result property="description" column="description"/>
<result property="layoutJson" column="layout_json"/>
<result property="active" column="is_active"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findAll" resultMap="templateMap">
SELECT id, tenant_code, template_code, template_name, template_type,
width, height, description, layout_json, is_active, created_at
FROM esn_template
<where>
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
<if test="templateType != null and templateType != ''">AND template_type = #{templateType}</if>
</where>
ORDER BY template_code
</select>
<select id="findById" resultMap="templateMap">
SELECT id, tenant_code, template_code, template_name, template_type,
width, height, description, layout_json, is_active, created_at
FROM esn_template WHERE id = #{id}
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO esn_template (tenant_code, template_code, template_name, template_type,
width, height, description, layout_json, is_active)
VALUES (#{tenantCode}, #{templateCode}, #{templateName}, #{templateType},
#{width}, #{height}, #{description}, #{layoutJson}, #{active})
</insert>
<update id="update">
UPDATE esn_template SET
template_name = #{templateName},
template_type = #{templateType},
width = #{width},
height = #{height},
description = #{description},
layout_json = #{layoutJson},
is_active = #{active}
WHERE id = #{id}
</update>
<delete id="delete">DELETE FROM esn_template WHERE id = #{id}</delete>
</mapper>

View File

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.esn.mapper.TenantMapper">
<resultMap id="tenantMap" type="com.zioinfo.esn.domain.TenantVo">
<id property="id" column="id"/>
<result property="tenantCode" column="tenant_code"/>
<result property="tenantName" column="tenant_name"/>
<result property="description" column="description"/>
<result property="active" column="is_active"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findAll" resultMap="tenantMap">
SELECT id, tenant_code, tenant_name, description, is_active, created_at
FROM esn_tenant ORDER BY tenant_code
</select>
<select id="findById" resultMap="tenantMap">
SELECT id, tenant_code, tenant_name, description, is_active, created_at
FROM esn_tenant WHERE id = #{id}
</select>
<select id="findByCode" resultMap="tenantMap">
SELECT id, tenant_code, tenant_name, description, is_active, created_at
FROM esn_tenant WHERE tenant_code = #{tenantCode}
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO esn_tenant (tenant_code, tenant_name, description, is_active)
VALUES (#{tenantCode}, #{tenantName}, #{description}, #{active})
</insert>
<update id="update">
UPDATE esn_tenant SET
tenant_name = #{tenantName},
description = #{description},
is_active = #{active}
WHERE id = #{id}
</update>
<delete id="delete">
DELETE FROM esn_tenant WHERE id = #{id}
</delete>
</mapper>

View File

@ -0,0 +1,30 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.esn.auth.mapper.UserAuthMapper">
<resultMap id="userMap" type="com.zioinfo.esn.auth.EsnUser">
<id property="id" column="id"/>
<result property="tenantCode" column="tenant_code"/>
<result property="username" column="username"/>
<result property="passwordHash" column="password_hash"/>
<result property="role" column="role"/>
<result property="email" column="email"/>
<result property="phone" column="phone"/>
<result property="active" column="is_active"/>
<result property="lastLoginAt" column="last_login_at"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findByUsername" resultMap="userMap">
SELECT id, tenant_code, username, password_hash, role, email, phone,
is_active, last_login_at, created_at
FROM esn_user
WHERE username = #{username}
</select>
<update id="updateLastLogin">
UPDATE esn_user SET last_login_at = NOW() WHERE username = #{username}
</update>
</mapper>

View File

@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.esn.mapper.UserMapper">
<resultMap id="userMap" type="com.zioinfo.esn.auth.EsnUser">
<id property="id" column="id"/>
<result property="tenantCode" column="tenant_code"/>
<result property="username" column="username"/>
<result property="role" column="role"/>
<result property="email" column="email"/>
<result property="phone" column="phone"/>
<result property="active" column="is_active"/>
<result property="lastLoginAt" column="last_login_at"/>
<result property="createdAt" column="created_at"/>
<!-- password_hash 는 resultMap에서 제외 — API 응답 미노출 -->
</resultMap>
<select id="findAll" resultMap="userMap">
SELECT id, tenant_code, username, role, email, phone, is_active, last_login_at, created_at
FROM esn_user
<where>
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
<if test="role != null and role != ''">AND role = #{role}</if>
</where>
ORDER BY username
</select>
<select id="findById" resultMap="userMap">
SELECT id, tenant_code, username, role, email, phone, is_active, last_login_at, created_at
FROM esn_user WHERE id = #{id}
</select>
<select id="findByUsername" resultType="com.zioinfo.esn.auth.EsnUser">
SELECT id, tenant_code, username, password_hash, role, email, phone,
is_active, last_login_at, created_at
FROM esn_user WHERE username = #{username}
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO esn_user (tenant_code, username, password_hash, role, email, phone, is_active)
VALUES (#{tenantCode}, #{username}, #{passwordHash}, #{role}, #{email}, #{phone}, #{active})
</insert>
<update id="update">
UPDATE esn_user SET
tenant_code = #{tenantCode},
role = #{role},
email = #{email},
phone = #{phone},
is_active = #{active}
WHERE id = #{id}
</update>
<update id="updatePassword">
UPDATE esn_user SET password_hash = #{passwordHash} WHERE id = #{id}
</update>
<delete id="delete">DELETE FROM esn_user WHERE id = #{id}</delete>
</mapper>

View File

@ -0,0 +1,75 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="com.zioinfo.esn.mapper.WorkMapper">
<resultMap id="workMap" type="com.zioinfo.esn.domain.WorkHistoryVo">
<id property="id" column="id"/>
<result property="tenantCode" column="tenant_code"/>
<result property="storeId" column="store_id"/>
<result property="storeName" column="store_name"/>
<result property="workType" column="work_type"/>
<result property="workContent" column="work_content"/>
<result property="workerName" column="worker_name"/>
<result property="status" column="status"/>
<result property="remarks" column="remarks"/>
<result property="startedAt" column="started_at"/>
<result property="completedAt" column="completed_at"/>
<result property="createdAt" column="created_at"/>
</resultMap>
<select id="findAll" resultMap="workMap">
SELECT w.id, w.tenant_code, w.store_id, s.store_name, w.work_type, w.work_content,
w.worker_name, w.status, w.remarks, w.started_at, w.completed_at, w.created_at
FROM esn_work_history w
LEFT JOIN esn_store s ON w.store_id = s.id
<where>
<if test="tenantCode != null and tenantCode != ''">AND w.tenant_code = #{tenantCode}</if>
<if test="storeId != null">AND w.store_id = #{storeId}</if>
<if test="status != null and status != ''">AND w.status = #{status}</if>
<if test="workType != null and workType != ''">AND w.work_type = #{workType}</if>
</where>
ORDER BY w.created_at DESC LIMIT 200
</select>
<select id="findById" resultMap="workMap">
SELECT w.id, w.tenant_code, w.store_id, s.store_name, w.work_type, w.work_content,
w.worker_name, w.status, w.remarks, w.started_at, w.completed_at, w.created_at
FROM esn_work_history w
LEFT JOIN esn_store s ON w.store_id = s.id
WHERE w.id = #{id}
</select>
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
INSERT INTO esn_work_history (tenant_code, store_id, work_type, work_content,
worker_name, status, remarks, started_at)
VALUES (#{tenantCode}, #{storeId}, #{workType}, #{workContent},
#{workerName}, #{status}, #{remarks}, #{startedAt})
</insert>
<update id="update">
UPDATE esn_work_history SET
work_type = #{workType},
work_content = #{workContent},
worker_name = #{workerName},
status = #{status},
remarks = #{remarks},
started_at = #{startedAt},
completed_at = #{completedAt}
WHERE id = #{id}
</update>
<delete id="delete">DELETE FROM esn_work_history WHERE id = #{id}</delete>
<select id="countToday" resultType="long">
SELECT COUNT(*) FROM esn_work_history WHERE created_at::date = CURRENT_DATE
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
</select>
<select id="countCompletedToday" resultType="long">
SELECT COUNT(*) FROM esn_work_history
WHERE status = 'COMPLETED' AND completed_at::date = CURRENT_DATE
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
</select>
</mapper>

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!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" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

30
frontend/package.json Normal file
View File

@ -0,0 +1,30 @@
{
"name": "zioinfo-esn-frontend",
"version": "1.0.0",
"private": true,
"scripts": {
"dev": "vite --port 3014",
"build": "tsc && vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.0",
"axios": "^1.7.0",
"@tanstack/react-query": "^5.0.0",
"recharts": "^2.12.0",
"lucide-react": "^0.400.0",
"date-fns": "^3.6.0"
},
"devDependencies": {
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.0",
"typescript": "^5.5.0",
"vite": "^5.3.0",
"tailwindcss": "^3.4.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0"
}
}

View File

@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

45
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,45 @@
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import Layout from './components/Layout'
import Login from './pages/Login'
import Dashboard from './pages/Dashboard'
import StoreList from './pages/StoreList'
import TemplateList from './pages/TemplateList'
import PosCvtList from './pages/PosCvtList'
import AlarmList from './pages/AlarmList'
import HCoreStatus from './pages/HCoreStatus'
import WorkHistory from './pages/WorkHistory'
import FirmwareList from './pages/FirmwareList'
import UserList from './pages/UserList'
import ProductList from './pages/ProductList'
import TenantAdmin from './pages/TenantAdmin'
import AiAnalysis from './pages/AiAnalysis'
const qc = new QueryClient()
export default function App() {
return (
<QueryClientProvider client={qc}>
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route element={<Layout />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/stores" element={<StoreList />} />
<Route path="/templates" element={<TemplateList />} />
<Route path="/pos-cvt" element={<PosCvtList />} />
<Route path="/alarms" element={<AlarmList />} />
<Route path="/hcore" element={<HCoreStatus />} />
<Route path="/works" element={<WorkHistory />} />
<Route path="/firmware" element={<FirmwareList />} />
<Route path="/products" element={<ProductList />} />
<Route path="/users" element={<UserList />} />
<Route path="/tenants" element={<TenantAdmin />} />
<Route path="/ai" element={<AiAnalysis />} />
</Route>
<Route path="*" element={<Navigate to="/dashboard" replace />} />
</Routes>
</BrowserRouter>
</QueryClientProvider>
)
}

105
frontend/src/api/client.ts Normal file
View File

@ -0,0 +1,105 @@
import axios from 'axios'
const api = axios.create({ baseURL: '' })
api.interceptors.request.use(config => {
const token = localStorage.getItem('esn_token')
if (token) config.headers.Authorization = `Bearer ${token}`
return config
})
api.interceptors.response.use(
res => res,
err => {
if (err.response?.status === 401) {
localStorage.removeItem('esn_token')
if (location.pathname !== '/login') location.href = '/login'
}
return Promise.reject(err)
}
)
export default api
const unwrap = (p: Promise<any>) => p.then(r => r.data?.data)
// ── Auth ──────────────────────────────────────────────────────────────────
export const login = (username: string, password: string) =>
api.post('/api/auth/login', { username, password })
export const getMe = () => api.get('/api/auth/me')
export const logout = () => api.post('/api/auth/logout')
// ── Dashboard ────────────────────────────────────────────────────────────
export const getDashboard = (tenantCode?: string) =>
unwrap(api.get(`/api/dashboard${tenantCode ? `?tenantCode=${tenantCode}` : ''}`))
// ── Tenants ──────────────────────────────────────────────────────────────
export const getTenants = () => unwrap(api.get('/api/tenants'))
export const createTenant = (d: object) => unwrap(api.post('/api/tenants', d))
export const updateTenant = (id: number, d: object) => unwrap(api.put(`/api/tenants/${id}`, d))
export const deleteTenant = (id: number) => api.delete(`/api/tenants/${id}`)
// ── Stores ───────────────────────────────────────────────────────────────
export const getStores = (params?: object) => unwrap(api.get('/api/stores', { params }))
export const getStore = (id: number) => unwrap(api.get(`/api/stores/${id}`))
export const createStore = (d: object) => unwrap(api.post('/api/stores', d))
export const updateStore = (id: number, d: object) => unwrap(api.put(`/api/stores/${id}`, d))
export const deleteStore = (id: number) => api.delete(`/api/stores/${id}`)
// ── Store Groups ─────────────────────────────────────────────────────────
export const getStoreGroups = (tenantCode?: string) =>
unwrap(api.get('/api/store-groups', { params: { tenantCode } }))
export const createStoreGroup = (d: object) => unwrap(api.post('/api/store-groups', d))
export const updateStoreGroup = (id: number, d: object) => unwrap(api.put(`/api/store-groups/${id}`, d))
export const deleteStoreGroup = (id: number) => api.delete(`/api/store-groups/${id}`)
// ── Templates ────────────────────────────────────────────────────────────
export const getTemplates = (params?: object) => unwrap(api.get('/api/templates', { params }))
export const createTemplate = (d: object) => unwrap(api.post('/api/templates', d))
export const updateTemplate = (id: number, d: object) => unwrap(api.put(`/api/templates/${id}`, d))
export const deleteTemplate = (id: number) => api.delete(`/api/templates/${id}`)
// ── POS CVT ──────────────────────────────────────────────────────────────
export const getPosCvt = (params?: object) => unwrap(api.get('/api/pos-cvt', { params }))
export const processPosCvt = (id: number) => unwrap(api.put(`/api/pos-cvt/${id}/process`))
export const ignorePosCvt = (id: number) => unwrap(api.put(`/api/pos-cvt/${id}/ignore`))
// ── Alarms ───────────────────────────────────────────────────────────────
export const getAlarms = (params?: object) => unwrap(api.get('/api/alarms', { params }))
export const createAlarm = (d: object) => unwrap(api.post('/api/alarms', d))
export const resolveAlarm = (id: number, resolution: string) =>
unwrap(api.put(`/api/alarms/${id}/resolve`, { resolution }))
export const deleteAlarm = (id: number) => api.delete(`/api/alarms/${id}`)
// ── HCore ────────────────────────────────────────────────────────────────
export const getHCore = (params?: object) => unwrap(api.get('/api/hcore', { params }))
export const updateHCoreStatus = (id: number, status: string) =>
unwrap(api.put(`/api/hcore/${id}/status`, { status }))
// ── Works ────────────────────────────────────────────────────────────────
export const getWorks = (params?: object) => unwrap(api.get('/api/works', { params }))
export const createWork = (d: object) => unwrap(api.post('/api/works', d))
export const updateWork = (id: number, d: object) => unwrap(api.put(`/api/works/${id}`, d))
export const deleteWork = (id: number) => api.delete(`/api/works/${id}`)
// ── Firmware ─────────────────────────────────────────────────────────────
export const getFirmware = (params?: object) => unwrap(api.get('/api/firmware', { params }))
export const createFirmware = (d: object) => unwrap(api.post('/api/firmware', d))
export const deleteFirmware = (id: number) => api.delete(`/api/firmware/${id}`)
// ── Users ────────────────────────────────────────────────────────────────
export const getUsers = (params?: object) => unwrap(api.get('/api/users', { params }))
export const createUser = (d: object) => unwrap(api.post('/api/users', d))
export const updateUser = (id: number, d: object) => unwrap(api.put(`/api/users/${id}`, d))
export const deleteUser = (id: number) => api.delete(`/api/users/${id}`)
// ── Products ─────────────────────────────────────────────────────────────
export const getProducts = (params?: object) => unwrap(api.get('/api/products', { params }))
export const createProduct = (d: object) => unwrap(api.post('/api/products', 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}`)
// ── 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))
export const aiChat = (message: string) => unwrap(api.post('/api/ai/chat', { message }))

View File

@ -0,0 +1,16 @@
interface AlarmBadgeProps {
severity: string
}
export default function AlarmBadge({ severity }: AlarmBadgeProps) {
const map: Record<string, string> = {
CRITICAL: 'bg-red-500/20 text-red-400 border border-red-500/40',
HIGH: 'bg-orange-500/20 text-orange-400 border border-orange-500/40',
MEDIUM: 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/40',
LOW: 'bg-gray-500/20 text-gray-400 border border-gray-500/40',
}
const cls = map[severity] || map.LOW
return (
<span className={`px-2 py-0.5 rounded text-xs font-medium ${cls}`}>{severity}</span>
)
}

View File

@ -0,0 +1,34 @@
import { useNavigate } from 'react-router-dom'
import { LogOut, User } from 'lucide-react'
export default function Header() {
const navigate = useNavigate()
const user = localStorage.getItem('esn_user') || 'admin'
const role = localStorage.getItem('esn_role') || 'ADMIN'
const tenant = localStorage.getItem('esn_tenant') || 'ALL'
function handleLogout() {
localStorage.removeItem('esn_token')
localStorage.removeItem('esn_role')
localStorage.removeItem('esn_user')
localStorage.removeItem('esn_tenant')
navigate('/login')
}
return (
<header className="h-14 bg-panel border-b border-edge flex items-center px-6 gap-4 flex-shrink-0">
<div className="flex-1" />
<span className="text-xs text-gray-400">: <span className="text-brand">{tenant}</span></span>
<span className="text-xs text-gray-400">|</span>
<div className="flex items-center gap-2 text-sm text-gray-300">
<User size={14} />
<span>{user}</span>
<span className="text-xs text-brand bg-brand/10 px-2 py-0.5 rounded">{role}</span>
</div>
<button onClick={handleLogout} className="flex items-center gap-1.5 text-gray-400 hover:text-white text-sm">
<LogOut size={14} />
</button>
</header>
)
}

View File

@ -0,0 +1,35 @@
import { useEffect } from 'react'
import { Outlet, Navigate } from 'react-router-dom'
import Sidebar from './Sidebar'
import Header from './Header'
import { getMe } from '../api/client'
export default function Layout() {
const token = localStorage.getItem('esn_token')
useEffect(() => {
if (!token) return
getMe()
.then(r => {
const me = r.data?.data || {}
if (me.role) localStorage.setItem('esn_role', me.role)
if (me.username) localStorage.setItem('esn_user', me.username)
if (me.tenant) localStorage.setItem('esn_tenant', me.tenant)
})
.catch(() => {})
}, [token])
if (!token) return <Navigate to="/login" replace />
return (
<div className="flex h-screen bg-ink">
<Sidebar />
<div className="flex-1 flex flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-auto p-6">
<Outlet />
</main>
</div>
</div>
)
}

View File

@ -0,0 +1,52 @@
import { NavLink } from 'react-router-dom'
import {
LayoutDashboard, Store, FileText, RefreshCw,
Bell, Cpu, ClipboardList, Zap, Users, Package, Building2, Brain
} from 'lucide-react'
const nav = [
{ to: '/dashboard', icon: LayoutDashboard, label: '대시보드' },
{ to: '/stores', icon: Store, label: '매장 관리' },
{ to: '/templates', icon: FileText, label: 'ESL 템플릿' },
{ to: '/pos-cvt', icon: RefreshCw, label: 'POS 변환' },
{ to: '/alarms', icon: Bell, label: '알람 관리' },
{ to: '/hcore', icon: Cpu, label: 'HCore 장치' },
{ to: '/works', icon: ClipboardList, label: '작업 이력' },
{ to: '/firmware', icon: Zap, label: '펌웨어' },
{ to: '/products', icon: Package, label: '상품/가격' },
{ to: '/users', icon: Users, label: '사용자' },
{ to: '/tenants', icon: Building2, label: '테넌트 관리' },
{ to: '/ai', icon: Brain, label: 'AI 분석' },
]
export default function Sidebar() {
return (
<aside className="w-56 bg-panel flex flex-col border-r border-edge flex-shrink-0">
<div className="h-14 flex items-center px-4 border-b border-edge">
<span className="text-brand font-bold text-sm tracking-wide">ESN</span>
<span className="ml-2 text-xs text-gray-400">ESL </span>
</div>
<nav className="flex-1 overflow-y-auto py-3">
{nav.map(({ to, icon: Icon, label }) => (
<NavLink
key={to}
to={to}
className={({ isActive }) =>
`flex items-center gap-3 px-4 py-2.5 text-sm transition-colors ${
isActive
? 'bg-brand/15 text-brand border-r-2 border-brand'
: 'text-gray-400 hover:text-white hover:bg-edge/50'
}`
}
>
<Icon size={16} />
{label}
</NavLink>
))}
</nav>
<div className="p-4 border-t border-edge text-xs text-gray-500">
zioinfo-esn v1.0
</div>
</aside>
)
}

View File

@ -0,0 +1,34 @@
import { LucideIcon } from 'lucide-react'
interface StatCardProps {
title: string
value: number | string
icon: LucideIcon
color?: string
sub?: string
}
export default function StatCard({ title, value, icon: Icon, color = 'brand', sub }: StatCardProps) {
const colorMap: Record<string, string> = {
brand: 'text-brand bg-brand/10',
green: 'text-green-400 bg-green-400/10',
red: 'text-red-400 bg-red-400/10',
orange: 'text-orange-400 bg-orange-400/10',
yellow: 'text-yellow-400 bg-yellow-400/10',
accent: 'text-accent bg-accent/10',
}
const cls = colorMap[color] || colorMap.brand
return (
<div className="bg-card border border-edge rounded-lg p-4 flex items-center gap-4">
<div className={`p-3 rounded-lg ${cls}`}>
<Icon size={20} />
</div>
<div>
<p className="text-xs text-gray-400">{title}</p>
<p className={`text-2xl font-bold ${cls.split(' ')[0]}`}>{value}</p>
{sub && <p className="text-xs text-gray-500">{sub}</p>}
</div>
</div>
)
}

13
frontend/src/index.css Normal file
View File

@ -0,0 +1,13 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
@apply bg-ink text-white;
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
}
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: #131927; }
::-webkit-scrollbar-thumb { background: #26304a; border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #00a0c8; }

10
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>
)

View File

@ -0,0 +1,85 @@
import { useState } from 'react'
import { useMutation } from '@tanstack/react-query'
import { analyzeAlarm, classifyPos } from '../api/client'
import { Brain, Zap, AlertTriangle, Tag } from 'lucide-react'
export default function AiAnalysis() {
const [alarmText, setAlarmText] = useState('')
const [alarmResult, setAlarmResult] = useState<string>('')
const [posText, setPosText] = useState('')
const [posResult, setPosResult] = useState<string>('')
const alarmMut = useMutation({
mutationFn: analyzeAlarm,
onSuccess: (data: any) => setAlarmResult(data.analysis || JSON.stringify(data)),
onError: () => setAlarmResult('Ollama 응답 실패 — 서비스를 확인해주세요.'),
})
const posMut = useMutation({
mutationFn: classifyPos,
onSuccess: (data: any) => setPosResult(data.category || JSON.stringify(data)),
onError: () => setPosResult('Ollama 응답 실패 — 서비스를 확인해주세요.'),
})
return (
<div className="space-y-6">
<h1 className="text-xl font-semibold text-white flex items-center gap-2">
<Brain size={20} className="text-brand" /> AI
</h1>
{/* Alarm Analysis */}
<div className="bg-card border border-edge rounded-lg p-5">
<h2 className="text-sm font-medium text-white mb-3 flex items-center gap-2">
<AlertTriangle size={16} className="text-yellow-400" />
</h2>
<textarea
value={alarmText}
onChange={e => setAlarmText(e.target.value)}
rows={4}
placeholder="알람 메시지를 입력하세요&#10;예: ESL-001 배터리 부족 경고 — Store: LG이노텍 구미, Device: ESL-A123"
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-gray-300 placeholder-gray-600 resize-none mb-3"
/>
<button
onClick={() => alarmMut.mutate({ message: alarmText })}
disabled={!alarmText.trim() || alarmMut.isPending}
className="flex items-center gap-2 px-4 py-2 bg-brand/20 text-brand border border-brand/40 rounded hover:bg-brand/30 disabled:opacity-40 text-sm"
>
<Zap size={14} />
{alarmMut.isPending ? '분석 중...' : 'Ollama 분석'}
</button>
{alarmResult && (
<div className="mt-4 bg-panel border border-edge rounded p-4 text-sm text-gray-300 whitespace-pre-wrap">
{alarmResult}
</div>
)}
</div>
{/* POS Classification */}
<div className="bg-card border border-edge rounded-lg p-5">
<h2 className="text-sm font-medium text-white mb-3 flex items-center gap-2">
<Tag size={16} className="text-accent" /> POS
</h2>
<textarea
value={posText}
onChange={e => setPosText(e.target.value)}
rows={4}
placeholder="POS 데이터를 입력하세요&#10;예: 상품코드=P001, 상품명=과자, 가격=1500, 판매수량=100, 점포=이마트 광명점"
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-gray-300 placeholder-gray-600 resize-none mb-3"
/>
<button
onClick={() => posMut.mutate({ data: posText })}
disabled={!posText.trim() || posMut.isPending}
className="flex items-center gap-2 px-4 py-2 bg-accent/20 text-accent border border-accent/40 rounded hover:bg-accent/30 disabled:opacity-40 text-sm"
>
<Zap size={14} />
{posMut.isPending ? '분류 중...' : 'Ollama 분류'}
</button>
{posResult && (
<div className="mt-4 bg-panel border border-edge rounded p-4 text-sm text-gray-300 whitespace-pre-wrap">
{posResult}
</div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,105 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { CheckCircle, Trash2 } from 'lucide-react'
import AlarmBadge from '../components/AlarmBadge'
import { getAlarms, resolveAlarm, deleteAlarm } from '../api/client'
export default function AlarmList() {
const qc = useQueryClient()
const [severity, setSeverity] = useState('')
const [status, setStatus] = useState('OPEN')
const { data: alarms = [], isLoading } = useQuery({
queryKey: ['alarms', severity, status],
queryFn: () => getAlarms({ severity: severity || undefined, status: status || undefined }),
})
const resolveMut = useMutation({
mutationFn: ({ id, resolution }: { id: number, resolution: string }) =>
resolveAlarm(id, resolution),
onSuccess: () => qc.invalidateQueries({ queryKey: ['alarms'] }),
})
const deleteMut = useMutation({
mutationFn: deleteAlarm,
onSuccess: () => qc.invalidateQueries({ queryKey: ['alarms'] }),
})
if (isLoading) return <div className="text-gray-400 p-8"> ...</div>
return (
<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">
<select value={severity} onChange={e => setSeverity(e.target.value)}
className="bg-panel border border-edge rounded px-3 py-1.5 text-sm text-gray-300">
<option value=""> </option>
{['CRITICAL','HIGH','MEDIUM','LOW'].map(s =>
<option key={s} value={s}>{s}</option>)}
</select>
<select value={status} onChange={e => setStatus(e.target.value)}
className="bg-panel border border-edge rounded px-3 py-1.5 text-sm text-gray-300">
<option value=""> </option>
{['OPEN','ACKNOWLEDGED','RESOLVED'].map(s =>
<option key={s} value={s}>{s}</option>)}
</select>
</div>
</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>
{['매장','유형','심각도','메시지','상태','발생일시','액션'].map(h => (
<th key={h} className="px-4 py-3 text-left text-xs text-gray-400 font-medium">{h}</th>
))}
</tr>
</thead>
<tbody>
{(alarms as any[]).map((a: any) => (
<tr key={a.id} className="border-b border-edge hover:bg-edge/30 transition-colors">
<td className="px-4 py-3 text-gray-300">{a.storeName || '-'}</td>
<td className="px-4 py-3 text-gray-300">{a.alarmType}</td>
<td className="px-4 py-3"><AlarmBadge severity={a.severity} /></td>
<td className="px-4 py-3 text-gray-400 max-w-xs truncate">{a.message}</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs ${
a.status === 'RESOLVED' ? 'bg-green-500/20 text-green-400' :
a.status === 'OPEN' ? 'bg-red-500/20 text-red-400' :
'bg-yellow-500/20 text-yellow-400'
}`}>{a.status}</span>
</td>
<td className="px-4 py-3 text-gray-500 text-xs">
{a.createdAt ? new Date(a.createdAt).toLocaleString('ko-KR') : '-'}
</td>
<td className="px-4 py-3">
<div className="flex gap-2">
{a.status !== 'RESOLVED' && (
<button
onClick={() => resolveMut.mutate({ id: a.id, resolution: '운영자 확인 후 해결' })}
className="text-green-400 hover:text-green-300"
title="해결 처리"
>
<CheckCircle size={14} />
</button>
)}
<button
onClick={() => { if (confirm('삭제?')) deleteMut.mutate(a.id) }}
className="text-red-400 hover:text-red-300"
>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
))}
{(alarms as any[]).length === 0 && (
<tr><td colSpan={7} className="px-4 py-8 text-center text-gray-500"> </td></tr>
)}
</tbody>
</table>
</div>
</div>
)
}

View File

@ -0,0 +1,101 @@
import { useQuery } from '@tanstack/react-query'
import { BarChart, Bar, XAxis, YAxis, Tooltip, ResponsiveContainer, PieChart, Pie, Cell } from 'recharts'
import { Store, Bell, Cpu, ClipboardList, RefreshCw, AlertTriangle } from 'lucide-react'
import StatCard from '../components/StatCard'
import { getDashboard } from '../api/client'
const COLORS = ['#00a0c8','#3ddc97','#f59e0b','#ef4444']
export default function Dashboard() {
const tenant = localStorage.getItem('esn_tenant') || undefined
const { data: d, isLoading } = useQuery({
queryKey: ['dashboard', tenant],
queryFn: () => getDashboard(tenant),
refetchInterval: 30000,
})
if (isLoading) return <div className="text-gray-400 p-8"> ...</div>
if (!d) return <div className="text-red-400 p-8"> .</div>
const deviceData = [
{ name: 'ONLINE', value: d.onlineDevices || 0 },
{ name: 'OFFLINE', value: d.offlineDevices || 0 },
]
const workData = [
{ name: '오늘 작업', value: d.totalWorkToday || 0 },
{ name: '완료', value: d.completedWorkToday || 0 },
{ name: 'POS 대기', value: d.pendingPosCvt || 0 },
{ name: 'POS 처리', value: d.processedPosCvtToday || 0 },
]
return (
<div className="space-y-6">
<h1 className="text-xl font-semibold text-white"></h1>
{/* 주요 지표 */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
<StatCard title="전체 매장" value={d.totalStores || 0} icon={Store} color="brand" />
<StatCard title="운영 매장" value={d.activeStores || 0} icon={Store} color="green" />
<StatCard title="미해결 알람" value={d.unresolvedAlarms || 0} icon={Bell} color="red" />
<StatCard title="긴급 알람" value={d.criticalAlarms || 0} icon={AlertTriangle} color="red" />
<StatCard title="온라인 장치" value={d.onlineDevices || 0} icon={Cpu} color="green" />
<StatCard title="POS 대기" value={d.pendingPosCvt || 0} icon={RefreshCw} color="yellow" />
</div>
{/* 차트 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="bg-card border border-edge rounded-lg p-4">
<h2 className="text-sm font-medium text-gray-300 mb-4"> </h2>
<ResponsiveContainer width="100%" height={200}>
<PieChart>
<Pie data={deviceData} cx="50%" cy="50%" outerRadius={70}
dataKey="value" label={({ name, value }) => `${name}: ${value}`}>
{deviceData.map((_, i) => (
<Cell key={i} fill={COLORS[i % COLORS.length]} />
))}
</Pie>
<Tooltip contentStyle={{ background: '#1a2234', border: '1px solid #26304a', color: '#fff' }} />
</PieChart>
</ResponsiveContainer>
</div>
<div className="bg-card border border-edge rounded-lg p-4">
<h2 className="text-sm font-medium text-gray-300 mb-4"> /POS </h2>
<ResponsiveContainer width="100%" height={200}>
<BarChart data={workData}>
<XAxis dataKey="name" tick={{ fill: '#9ca3af', fontSize: 11 }} />
<YAxis tick={{ fill: '#9ca3af', fontSize: 11 }} />
<Tooltip contentStyle={{ background: '#1a2234', border: '1px solid #26304a', color: '#fff' }} />
<Bar dataKey="value" fill="#00a0c8" radius={[3,3,0,0]} />
</BarChart>
</ResponsiveContainer>
</div>
</div>
{/* 요약 카드 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div className="bg-card border border-edge rounded-lg p-4">
<p className="text-xs text-gray-400"> </p>
<p className="text-2xl font-bold text-brand">{d.totalWorkToday || 0}</p>
<p className="text-xs text-gray-500">: {d.completedWorkToday || 0}</p>
</div>
<div className="bg-card border border-edge rounded-lg p-4">
<p className="text-xs text-gray-400"> </p>
<p className="text-2xl font-bold text-yellow-400">{d.totalAlarms || 0}</p>
<p className="text-xs text-gray-500">: {d.unresolvedAlarms || 0}</p>
</div>
<div className="bg-card border border-edge rounded-lg p-4">
<p className="text-xs text-gray-400"> </p>
<p className="text-2xl font-bold text-accent">{d.totalDevices || 0}</p>
<p className="text-xs text-gray-500">: {d.offlineDevices || 0}</p>
</div>
<div className="bg-card border border-edge rounded-lg p-4">
<p className="text-xs text-gray-400">POS ()</p>
<p className="text-2xl font-bold text-green-400">{d.processedPosCvtToday || 0}</p>
<p className="text-xs text-gray-500">: {d.pendingPosCvt || 0}</p>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,64 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Trash2, Star } from 'lucide-react'
import { getFirmware, deleteFirmware } from '../api/client'
export default function FirmwareList() {
const qc = useQueryClient()
const { data: fws = [], isLoading } = useQuery({
queryKey: ['firmware'],
queryFn: () => getFirmware(),
})
const deleteMut = useMutation({
mutationFn: deleteFirmware,
onSuccess: () => qc.invalidateQueries({ queryKey: ['firmware'] }),
})
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>
<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>
{(fws as any[]).map((f: any) => (
<tr key={f.id} className="border-b border-edge hover:bg-edge/30">
<td className="px-4 py-3 text-brand font-mono text-xs">{f.firmwareVersion}</td>
<td className="px-4 py-3 text-gray-400 text-xs">{f.tenantCode}</td>
<td className="px-4 py-3 text-gray-300 text-xs">{f.deviceType}</td>
<td className="px-4 py-3 text-gray-300 text-xs">{f.fileName}</td>
<td className="px-4 py-3 text-gray-400 text-xs">
{f.fileSize ? `${(f.fileSize / 1024).toFixed(1)}KB` : '-'}
</td>
<td className="px-4 py-3 text-gray-500 text-xs font-mono truncate max-w-[80px]">
{f.checksum ? f.checksum.substring(0, 8) + '...' : '-'}
</td>
<td className="px-4 py-3">
{f.latest && <Star size={14} className="text-yellow-400" fill="currentColor" />}
</td>
<td className="px-4 py-3 text-gray-500 text-xs">
{f.createdAt ? new Date(f.createdAt).toLocaleDateString('ko-KR') : '-'}
</td>
<td className="px-4 py-3">
<button onClick={() => { if(confirm('삭제?')) deleteMut.mutate(f.id) }}
className="text-red-400 hover:text-red-300"><Trash2 size={14} /></button>
</td>
</tr>
))}
{(fws 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

@ -0,0 +1,94 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Cpu, Wifi, WifiOff, Battery, AlertTriangle } from 'lucide-react'
import { getHCore, updateHCoreStatus } from '../api/client'
const statusIcon: Record<string, React.ReactNode> = {
ONLINE: <Wifi size={14} className="text-green-400" />,
OFFLINE: <WifiOff size={14} className="text-red-400" />,
ERROR: <AlertTriangle size={14} className="text-orange-400" />,
UPDATING: <Cpu size={14} className="text-yellow-400" />,
}
const statusColor: Record<string, string> = {
ONLINE: 'border-green-500/30 bg-green-500/5',
OFFLINE: 'border-red-500/30 bg-red-500/5',
ERROR: 'border-orange-500/30 bg-orange-500/5',
UPDATING: 'border-yellow-500/30 bg-yellow-500/5',
}
export default function HCoreStatus() {
const qc = useQueryClient()
const [deviceType, setDeviceType] = React.useState('')
const [status, setStatus] = React.useState('')
const { data: devices = [], isLoading } = useQuery({
queryKey: ['hcore', deviceType, status],
queryFn: () => getHCore({ deviceType: deviceType || undefined, status: status || undefined }),
refetchInterval: 15000,
})
const statusMut = useMutation({
mutationFn: ({ id, status }: { id: number; status: string }) => updateHCoreStatus(id, status),
onSuccess: () => qc.invalidateQueries({ queryKey: ['hcore'] }),
})
if (isLoading) return <div className="text-gray-400 p-8"> ...</div>
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-white">HCore </h1>
<div className="flex gap-2">
<select value={deviceType} onChange={e => setDeviceType(e.target.value)}
className="bg-panel border border-edge rounded px-3 py-1.5 text-sm text-gray-300">
<option value=""> </option>
{['GATEWAY','HUB','ESL_DEVICE'].map(t => <option key={t} value={t}>{t}</option>)}
</select>
<select value={status} onChange={e => setStatus(e.target.value)}
className="bg-panel border border-edge rounded px-3 py-1.5 text-sm text-gray-300">
<option value=""> </option>
{['ONLINE','OFFLINE','ERROR','UPDATING'].map(s => <option key={s} value={s}>{s}</option>)}
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
{(devices as any[]).map((d: any) => (
<div key={d.id} className={`border rounded-lg p-4 ${statusColor[d.status] || 'border-edge bg-card'}`}>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
{statusIcon[d.status]}
<span className="text-xs font-mono text-gray-300">{d.deviceId}</span>
</div>
<span className="text-xs text-brand">{d.deviceType}</span>
</div>
<p className="text-xs text-gray-400">{d.storeName || '미배정'}</p>
<p className="text-xs text-gray-500 mt-1">FW: {d.firmwareVersion || '-'}</p>
{d.ipAddress && <p className="text-xs text-gray-600 font-mono">{d.ipAddress}</p>}
{d.batteryLevel != null && (
<div className="flex items-center gap-1 mt-2">
<Battery size={12} className={d.batteryLevel < 20 ? 'text-red-400' : 'text-gray-400'} />
<span className={`text-xs ${d.batteryLevel < 20 ? 'text-red-400' : 'text-gray-400'}`}>
{d.batteryLevel}%
</span>
</div>
)}
{d.status === 'OFFLINE' && (
<button
onClick={() => statusMut.mutate({ id: d.id, status: 'ONLINE' })}
className="mt-2 text-xs text-brand hover:underline"
>
</button>
)}
</div>
))}
{(devices as any[]).length === 0 && (
<div className="col-span-4 text-center text-gray-500 py-8"> </div>
)}
</div>
</div>
)
}
import React from 'react'

View File

@ -0,0 +1,68 @@
import { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { login } from '../api/client'
export default function Login() {
const navigate = useNavigate()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setLoading(true)
setError('')
try {
const res = await login(username, password)
const token = res.data?.data?.token
if (!token) throw new Error('토큰 없음')
localStorage.setItem('esn_token', token)
navigate('/dashboard')
} catch {
setError('로그인 실패: 아이디/비밀번호를 확인하세요.')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen bg-ink flex items-center justify-center">
<div className="w-full max-w-sm">
<div className="text-center mb-8">
<h1 className="text-2xl font-bold text-brand">zioinfo-esn</h1>
<p className="text-sm text-gray-400 mt-1">ESL </p>
</div>
<form onSubmit={handleSubmit} className="bg-card border border-edge rounded-lg p-6 space-y-4">
<div>
<label className="block text-xs text-gray-400 mb-1"></label>
<input
value={username}
onChange={e => setUsername(e.target.value)}
placeholder="admin"
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white placeholder-gray-600 focus:border-brand focus:outline-none"
/>
</div>
<div>
<label className="block text-xs text-gray-400 mb-1"></label>
<input
type="password"
value={password}
onChange={e => setPassword(e.target.value)}
placeholder="••••••••"
className="w-full bg-panel border border-edge rounded px-3 py-2 text-sm text-white placeholder-gray-600 focus:border-brand focus:outline-none"
/>
</div>
{error && <p className="text-red-400 text-xs">{error}</p>}
<button
type="submit"
disabled={loading}
className="w-full bg-brand hover:bg-brand2 text-white py-2 rounded text-sm font-medium transition-colors disabled:opacity-50"
>
{loading ? '로그인 중...' : '로그인'}
</button>
</form>
</div>
</div>
)
}

View File

@ -0,0 +1,94 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { CheckCircle, XCircle, MinusCircle } from 'lucide-react'
import { getPosCvt, processPosCvt, ignorePosCvt } from '../api/client'
export default function PosCvtList() {
const qc = useQueryClient()
const [status, setStatus] = useState('PENDING')
const [keyword, setKeyword] = useState('')
const { data: items = [], isLoading } = useQuery({
queryKey: ['pos-cvt', status, keyword],
queryFn: () => getPosCvt({ status: status || undefined, keyword: keyword || undefined }),
})
const processMut = useMutation({
mutationFn: processPosCvt,
onSuccess: () => qc.invalidateQueries({ queryKey: ['pos-cvt'] }),
})
const ignoreMut = useMutation({
mutationFn: ignorePosCvt,
onSuccess: () => qc.invalidateQueries({ queryKey: ['pos-cvt'] }),
})
if (isLoading) return <div className="text-gray-400 p-8"> ...</div>
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-white">POS </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" />
<select value={status} onChange={e => setStatus(e.target.value)}
className="bg-panel border border-edge rounded px-3 py-1.5 text-sm text-gray-300">
{['','PENDING','PROCESSED','ERROR','IGNORED'].map(s =>
<option key={s} value={s}>{s || '전체'}</option>)}
</select>
</div>
</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>
{['테넌트','매장','상품코드','상품명','정가','판매가','상태','수신일시','액션'].map(h => (
<th key={h} className="px-3 py-3 text-left text-xs text-gray-400 font-medium">{h}</th>
))}
</tr>
</thead>
<tbody>
{(items as any[]).map((p: any) => (
<tr key={p.id} className="border-b border-edge hover:bg-edge/30">
<td className="px-3 py-3 text-brand text-xs">{p.tenantCode}</td>
<td className="px-3 py-3 text-gray-300 text-xs">{p.storeName || '-'}</td>
<td className="px-3 py-3 text-gray-300 font-mono text-xs">{p.productCode}</td>
<td className="px-3 py-3 text-white text-xs">{p.productName}</td>
<td className="px-3 py-3 text-gray-300 text-xs">{p.price?.toLocaleString()}</td>
<td className="px-3 py-3 text-accent text-xs">{p.salePrice?.toLocaleString()}</td>
<td className="px-3 py-3">
<span className={`px-2 py-0.5 rounded text-xs ${
p.status === 'PROCESSED' ? 'bg-green-500/20 text-green-400' :
p.status === 'PENDING' ? 'bg-yellow-500/20 text-yellow-400' :
p.status === 'ERROR' ? 'bg-red-500/20 text-red-400' :
'bg-gray-500/20 text-gray-400'
}`}>{p.status}</span>
</td>
<td className="px-3 py-3 text-gray-500 text-xs">
{p.createdAt ? new Date(p.createdAt).toLocaleString('ko-KR') : '-'}
</td>
<td className="px-3 py-3">
{p.status === 'PENDING' && (
<div className="flex gap-2">
<button onClick={() => processMut.mutate(p.id)} title="처리" className="text-green-400 hover:text-green-300">
<CheckCircle size={14} />
</button>
<button onClick={() => ignoreMut.mutate(p.id)} title="무시" className="text-gray-400 hover:text-gray-300">
<MinusCircle size={14} />
</button>
</div>
)}
</td>
</tr>
))}
{(items 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

@ -0,0 +1,69 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Trash2 } from 'lucide-react'
import { getProducts, deleteProduct } from '../api/client'
export default function ProductList() {
const qc = useQueryClient()
const [keyword, setKeyword] = useState('')
const { data: products = [], isLoading } = useQuery({
queryKey: ['products', keyword],
queryFn: () => getProducts({ keyword: keyword || undefined }),
})
const deleteMut = useMutation({
mutationFn: deleteProduct,
onSuccess: () => qc.invalidateQueries({ queryKey: ['products'] }),
})
if (isLoading) return <div className="text-gray-400 p-8"> ...</div>
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-white">/ </h1>
<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" />
</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>
{['테넌트','매장','상품코드','상품명','카테고리','정가','판매가','화폐','상태','액션'].map(h => (
<th key={h} className="px-3 py-3 text-left text-xs text-gray-400 font-medium">{h}</th>
))}
</tr>
</thead>
<tbody>
{(products as any[]).map((p: any) => (
<tr key={p.id} className="border-b border-edge hover:bg-edge/30">
<td className="px-3 py-3 text-brand text-xs">{p.tenantCode}</td>
<td className="px-3 py-3 text-gray-400 text-xs">{p.storeName || '-'}</td>
<td className="px-3 py-3 text-gray-300 font-mono text-xs">{p.productCode}</td>
<td className="px-3 py-3 text-white">{p.productName}</td>
<td className="px-3 py-3 text-gray-400 text-xs">{p.category}</td>
<td className="px-3 py-3 text-gray-300 text-xs">{p.price?.toLocaleString()}</td>
<td className="px-3 py-3 text-accent text-xs font-medium">{p.salePrice?.toLocaleString()}</td>
<td className="px-3 py-3 text-gray-500 text-xs">{p.currency}</td>
<td className="px-3 py-3">
<span className={`px-2 py-0.5 rounded text-xs ${p.active ? 'bg-green-500/20 text-green-400' : 'bg-gray-500/20 text-gray-400'}`}>
{p.active ? '활성' : '비활성'}
</span>
</td>
<td className="px-3 py-3">
<button onClick={() => { if(confirm('삭제?')) deleteMut.mutate(p.id) }}
className="text-red-400 hover:text-red-300"><Trash2 size={14} /></button>
</td>
</tr>
))}
{(products as any[]).length === 0 && (
<tr><td colSpan={10} className="px-4 py-8 text-center text-gray-500"> </td></tr>
)}
</tbody>
</table>
</div>
</div>
)
}

View File

@ -0,0 +1,138 @@
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Edit, Trash2, X } from 'lucide-react'
import { getStores, createStore, updateStore, deleteStore } from '../api/client'
export default function StoreList() {
const qc = useQueryClient()
const [modal, setModal] = useState<any>(null)
const [keyword, setKeyword] = useState('')
const { data: stores = [], isLoading } = useQuery({
queryKey: ['stores', keyword],
queryFn: () => getStores({ keyword: keyword || undefined }),
})
const saveMut = useMutation({
mutationFn: (d: any) => d.id ? updateStore(d.id, d) : createStore(d),
onSuccess: () => { qc.invalidateQueries({ queryKey: ['stores'] }); setModal(null) },
})
const deleteMut = useMutation({
mutationFn: deleteStore,
onSuccess: () => qc.invalidateQueries({ queryKey: ['stores'] }),
})
if (isLoading) return <div className="text-gray-400 p-8"> ...</div>
return (
<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">
<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>
{(stores as any[]).map((s: any) => (
<tr key={s.id} className="border-b border-edge hover:bg-edge/30">
<td className="px-4 py-3 text-brand text-xs">{s.tenantCode}</td>
<td className="px-4 py-3 text-gray-300 font-mono text-xs">{s.storeCode}</td>
<td className="px-4 py-3 text-white">{s.storeName}</td>
<td className="px-4 py-3 text-gray-400 text-xs truncate max-w-xs">{s.address}</td>
<td className="px-4 py-3 text-gray-400">{s.managerName}</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs ${s.active ? 'bg-green-500/20 text-green-400' : 'bg-gray-500/20 text-gray-400'}`}>
{s.active ? '운영' : '중지'}
</span>
</td>
<td className="px-4 py-3">
<div className="flex gap-2">
<button onClick={() => setModal(s)} className="text-brand hover:text-blue-300"><Edit size={14} /></button>
<button onClick={() => { if(confirm('삭제?')) deleteMut.mutate(s.id) }} className="text-red-400 hover:text-red-300"><Trash2 size={14} /></button>
</div>
</td>
</tr>
))}
{(stores as any[]).length === 0 && (
<tr><td colSpan={7} 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>
<StoreForm initial={modal} onSave={(d: any) => saveMut.mutate(d)} />
</div>
</div>
)}
</div>
)
}
function StoreForm({ initial, onSave }: { initial: any; onSave: (d: any) => void }) {
const [f, setF] = useState({
tenantCode: initial.tenantCode || '',
storeCode: initial.storeCode || '',
storeName: initial.storeName || '',
address: initial.address || '',
phone: initial.phone || '',
managerName: initial.managerName || '',
active: initial.active !== false,
id: initial.id,
})
const set = (k: string) => (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
setF(p => ({ ...p, [k]: e.target.value }))
return (
<form onSubmit={e => { e.preventDefault(); onSave(f) }} className="space-y-3">
{[
{ label: '테넌트', key: 'tenantCode', type: 'select',
opts: ['LGINNOTEK','LGIT','EMART','ZIOINFO'] },
{ label: '매장코드', key: 'storeCode' },
{ label: '매장명', key: 'storeName' },
{ label: '주소', key: 'address' },
{ label: '전화', key: 'phone' },
{ label: '담당자', key: 'managerName' },
].map(({ label, key, type, opts }) => (
<div key={key}>
<label className="block text-xs text-gray-400 mb-1">{label}</label>
{type === 'select' ? (
<select 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">
{(opts || []).map(o => <option key={o} value={o}>{o}</option>)}
</select>
) : (
<input 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>
))}
<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,51 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Plus, Trash2 } from 'lucide-react'
import { getTemplates, createTemplate, deleteTemplate } from '../api/client'
export default function TemplateList() {
const qc = useQueryClient()
const { data: templates = [], isLoading } = useQuery({
queryKey: ['templates'],
queryFn: () => getTemplates(),
})
const deleteMut = useMutation({
mutationFn: deleteTemplate,
onSuccess: () => qc.invalidateQueries({ queryKey: ['templates'] }),
})
if (isLoading) return <div className="text-gray-400 p-8"> ...</div>
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-white">ESL 릿</h1>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{(templates as any[]).map((t: any) => (
<div key={t.id} className="bg-card border border-edge rounded-lg p-4">
<div className="flex justify-between items-start mb-2">
<div>
<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>
<button onClick={() => { if(confirm('삭제?')) deleteMut.mutate(t.id) }}
className="text-red-400 hover:text-red-300"><Trash2 size={14} /></button>
</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">
<p className="text-xs text-gray-400">{t.width}×{t.height}px</p>
<p className="text-xs text-gray-500 mt-1">{t.description}</p>
</div>
</div>
<p className="text-xs text-gray-500 mt-2">: {t.templateCode}</p>
</div>
))}
{(templates as any[]).length === 0 && (
<div className="col-span-3 text-center text-gray-500 py-8">릿 </div>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,52 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { getTenants, deleteTenant } from '../api/client'
import { Building2, Trash2 } from 'lucide-react'
export default function TenantAdmin() {
const qc = useQueryClient()
const { data: tenants = [], isLoading } = useQuery({
queryKey: ['tenants'],
queryFn: () => getTenants(),
})
const deleteMut = useMutation({
mutationFn: deleteTenant,
onSuccess: () => qc.invalidateQueries({ queryKey: ['tenants'] }),
})
if (isLoading) return <div className="text-gray-400 p-8"> ...</div>
const role = localStorage.getItem('esn_role')
if (role !== 'ADMIN') return (
<div className="text-center py-16">
<p className="text-gray-400"> .</p>
</div>
)
return (
<div className="space-y-4">
<h1 className="text-xl font-semibold text-white"> </h1>
<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">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Building2 size={18} className="text-brand" />
<span className="font-bold text-brand">{t.tenantCode}</span>
</div>
<button onClick={() => { if(confirm('삭제?')) deleteMut.mutate(t.id) }}
className="text-red-400 hover:text-red-300"><Trash2 size={14} /></button>
</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">
<span className={`px-2 py-0.5 rounded text-xs ${t.active ? 'bg-green-500/20 text-green-400' : 'bg-gray-500/20 text-gray-400'}`}>
{t.active ? '운영중' : '비활성'}
</span>
</div>
</div>
))}
</div>
</div>
)
}

View File

@ -0,0 +1,64 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Trash2 } from 'lucide-react'
import { getUsers, deleteUser } from '../api/client'
export default function UserList() {
const qc = useQueryClient()
const { data: users = [], isLoading } = useQuery({
queryKey: ['users'],
queryFn: () => getUsers(),
})
const deleteMut = useMutation({
mutationFn: deleteUser,
onSuccess: () => qc.invalidateQueries({ queryKey: ['users'] }),
})
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>
<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>
{(users as any[]).map((u: any) => (
<tr key={u.id} className="border-b border-edge hover:bg-edge/30">
<td className="px-4 py-3 text-white font-medium">{u.username}</td>
<td className="px-4 py-3 text-brand text-xs">{u.tenantCode || 'SUPER'}</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs ${
u.role === 'ADMIN' ? 'bg-red-500/20 text-red-400' :
u.role === 'MANAGER' ? 'bg-brand/20 text-brand' :
'bg-gray-500/20 text-gray-400'
}`}>{u.role}</span>
</td>
<td className="px-4 py-3 text-gray-400 text-xs">{u.email || '-'}</td>
<td className="px-4 py-3 text-gray-400 text-xs">{u.phone || '-'}</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs ${u.active ? 'bg-green-500/20 text-green-400' : 'bg-gray-500/20 text-gray-400'}`}>
{u.active ? '활성' : '비활성'}
</span>
</td>
<td className="px-4 py-3 text-gray-500 text-xs">
{u.lastLoginAt ? new Date(u.lastLoginAt).toLocaleString('ko-KR') : '-'}
</td>
<td className="px-4 py-3">
<button onClick={() => { if(confirm('삭제?')) deleteMut.mutate(u.id) }}
className="text-red-400 hover:text-red-300"><Trash2 size={14} /></button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

Some files were not shown because too many files have changed in this diff Show More