feat(esn): e-paper ESL 핵심 플로우 추가 — 태그 바인딩 + 가격 전파 큐
- esn_tag_binding: ESL 태그 ↔ 선반위치 ↔ 상품 매핑 테이블 - esn_update_queue: POS 가격변경 → 태그 RF 전파 추적 큐 - TagBindingController/Service/Mapper: 태그 바인딩 CRUD + 언바인딩 - UpdateQueueController/Service/Mapper: Gateway 폴링(/poll), 태그 ACK(/confirm), 실패재시도 - PosCvtService.process(): 가격 처리 시 바인딩된 e-paper 태그에 자동 큐 생성 - 프론트: TagBindingList.tsx (배터리/신호강도), UpdateQueueList.tsx (5초 자동갱신) - 사이드바: 태그 바인딩 / 업데이트 큐 메뉴 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
5774735d92
commit
67e1c5eeef
27
.gitignore
vendored
Normal file
27
.gitignore
vendored
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
# Maven build
|
||||||
|
target/
|
||||||
|
*.jar
|
||||||
|
*.war
|
||||||
|
*.class
|
||||||
|
|
||||||
|
# Node
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.vite/
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
*.log
|
||||||
|
logs/
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
@ -29,9 +29,9 @@ public class PosCvtController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}/process")
|
@PutMapping("/{id}/process")
|
||||||
public ApiResponse<Void> process(@PathVariable Long id) {
|
public ApiResponse<Integer> process(@PathVariable Long id) {
|
||||||
service.process(id);
|
int queued = service.process(id);
|
||||||
return ApiResponse.ok("처리 완료", null);
|
return ApiResponse.ok("처리 완료 (태그 큐 " + queued + "건)", queued);
|
||||||
}
|
}
|
||||||
|
|
||||||
@PutMapping("/{id}/ignore")
|
@PutMapping("/{id}/ignore")
|
||||||
|
|||||||
@ -0,0 +1,57 @@
|
|||||||
|
package com.zioinfo.esn.controller;
|
||||||
|
|
||||||
|
import com.zioinfo.esn.common.ApiResponse;
|
||||||
|
import com.zioinfo.esn.domain.TagBindingVo;
|
||||||
|
import com.zioinfo.esn.service.TagBindingService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* e-paper ESL 태그 ↔ 선반 위치 ↔ 상품 바인딩 관리
|
||||||
|
* 핵심 플로우: 태그 스캔 → 상품 선택 → 위치 지정 → bind() → 가격 변경 시 자동 큐
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/tag-binding")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TagBindingController {
|
||||||
|
private final TagBindingService service;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ApiResponse<List<TagBindingVo>> list(
|
||||||
|
@RequestParam(required = false) String tenantCode,
|
||||||
|
@RequestParam(required = false) Long storeId,
|
||||||
|
@RequestParam(required = false) String bindStatus,
|
||||||
|
@RequestParam(required = false) String keyword) {
|
||||||
|
return ApiResponse.ok(service.list(tenantCode, storeId, bindStatus, keyword));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ApiResponse<TagBindingVo> get(@PathVariable Long id) {
|
||||||
|
return ApiResponse.ok(service.get(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/device/{deviceId}")
|
||||||
|
public ApiResponse<TagBindingVo> getByDevice(@PathVariable String deviceId) {
|
||||||
|
return ApiResponse.ok(service.getByDeviceId(deviceId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 태그에 상품+위치 바인딩 */
|
||||||
|
@PostMapping
|
||||||
|
public ApiResponse<TagBindingVo> bind(@RequestBody TagBindingVo vo) {
|
||||||
|
return ApiResponse.ok(service.bind(vo));
|
||||||
|
}
|
||||||
|
|
||||||
|
@PutMapping("/{id}")
|
||||||
|
public ApiResponse<TagBindingVo> update(@PathVariable Long id, @RequestBody TagBindingVo vo) {
|
||||||
|
vo.setId(id);
|
||||||
|
return ApiResponse.ok(service.bind(vo));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 태그 언바인딩 (선반에서 태그 제거 시) */
|
||||||
|
@PutMapping("/{id}/unbind")
|
||||||
|
public ApiResponse<Void> unbind(@PathVariable Long id) {
|
||||||
|
service.unbind(id);
|
||||||
|
return ApiResponse.ok("언바인딩 완료", null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,75 @@
|
|||||||
|
package com.zioinfo.esn.controller;
|
||||||
|
|
||||||
|
import com.zioinfo.esn.common.ApiResponse;
|
||||||
|
import com.zioinfo.esn.domain.UpdateQueueVo;
|
||||||
|
import com.zioinfo.esn.service.UpdateQueueService;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* e-paper 태그 가격 업데이트 전파 큐
|
||||||
|
* Gateway가 이 API를 주기적으로 폴링 → 태그에 RF 전송 → confirm 호출
|
||||||
|
*
|
||||||
|
* Gateway 연동 흐름:
|
||||||
|
* GET /api/update-queue/poll?tenantCode=EMART&limit=50 → PENDING 목록 수신
|
||||||
|
* PUT /api/update-queue/{id}/sent → RF 전송 완료
|
||||||
|
* PUT /api/update-queue/confirm?deviceId=ESL-001 → 태그 ACK 수신
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/update-queue")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UpdateQueueController {
|
||||||
|
private final UpdateQueueService service;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public ApiResponse<List<UpdateQueueVo>> list(
|
||||||
|
@RequestParam(required = false) String tenantCode,
|
||||||
|
@RequestParam(required = false) Long storeId,
|
||||||
|
@RequestParam(required = false) String status) {
|
||||||
|
return ApiResponse.ok(service.list(tenantCode, storeId, status));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public ApiResponse<UpdateQueueVo> get(@PathVariable Long id) {
|
||||||
|
return ApiResponse.ok(service.get(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gateway 폴링 엔드포인트: PENDING → SENDING 전환 후 반환 */
|
||||||
|
@GetMapping("/poll")
|
||||||
|
public ApiResponse<List<UpdateQueueVo>> poll(
|
||||||
|
@RequestParam(required = false) String tenantCode,
|
||||||
|
@RequestParam(defaultValue = "50") int limit) {
|
||||||
|
return ApiResponse.ok(service.pollPending(tenantCode, limit));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gateway가 RF 전송 완료 후 호출 */
|
||||||
|
@PutMapping("/{id}/sent")
|
||||||
|
public ApiResponse<Void> sent(@PathVariable Long id) {
|
||||||
|
service.markSent(id);
|
||||||
|
return ApiResponse.ok("전송 완료", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 태그로부터 확인 응답 수신 (device_id 기반 일괄 확인) */
|
||||||
|
@PutMapping("/confirm")
|
||||||
|
public ApiResponse<Void> confirm(@RequestParam String deviceId) {
|
||||||
|
service.confirm(deviceId);
|
||||||
|
return ApiResponse.ok("확인 완료", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 전송 실패 처리 */
|
||||||
|
@PutMapping("/{id}/failed")
|
||||||
|
public ApiResponse<Void> failed(@PathVariable Long id,
|
||||||
|
@RequestBody Map<String, String> body) {
|
||||||
|
service.markFailed(id, body.getOrDefault("message", "전송 실패"));
|
||||||
|
return ApiResponse.ok("실패 처리 완료", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 큐 항목 취소 */
|
||||||
|
@PutMapping("/{id}/cancel")
|
||||||
|
public ApiResponse<Void> cancel(@PathVariable Long id) {
|
||||||
|
service.cancel(id);
|
||||||
|
return ApiResponse.ok("취소 완료", null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
package com.zioinfo.esn.domain;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class TagBindingVo {
|
||||||
|
private Long id;
|
||||||
|
private String tenantCode;
|
||||||
|
private Long storeId;
|
||||||
|
private String storeName;
|
||||||
|
private String deviceId; // ESL_DEVICE의 device_id
|
||||||
|
private String productCode;
|
||||||
|
private String productName;
|
||||||
|
private String locationCode; // 선반 위치 (예: A01-03)
|
||||||
|
private String templateCode;
|
||||||
|
private String displayData; // 현재 화면 렌더링 데이터 (JSON)
|
||||||
|
private BigDecimal lastPrice;
|
||||||
|
private LocalDateTime lastUpdatedAt;
|
||||||
|
private String bindStatus; // ACTIVE, UNBOUND, ERROR
|
||||||
|
// HCore 장치 정보 (join)
|
||||||
|
private String deviceStatus; // ONLINE, OFFLINE 등
|
||||||
|
private Integer batteryLevel;
|
||||||
|
private String signalStrength;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,31 @@
|
|||||||
|
package com.zioinfo.esn.domain;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import java.math.BigDecimal;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
@Data
|
||||||
|
public class UpdateQueueVo {
|
||||||
|
private Long id;
|
||||||
|
private String tenantCode;
|
||||||
|
private Long storeId;
|
||||||
|
private String storeName;
|
||||||
|
private Long bindingId;
|
||||||
|
private String deviceId;
|
||||||
|
private String locationCode;
|
||||||
|
private String productCode;
|
||||||
|
private String productName;
|
||||||
|
private BigDecimal newPrice;
|
||||||
|
private BigDecimal newSalePrice;
|
||||||
|
private String templateCode;
|
||||||
|
private String renderData;
|
||||||
|
private Integer priority; // 1(긴급)~10(일반)
|
||||||
|
private String status; // PENDING, SENDING, SENT, CONFIRMED, FAILED, CANCELLED
|
||||||
|
private Integer retryCount;
|
||||||
|
private Integer maxRetry;
|
||||||
|
private String errorMessage;
|
||||||
|
private Long posCvtId;
|
||||||
|
private LocalDateTime sentAt;
|
||||||
|
private LocalDateTime confirmedAt;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
package com.zioinfo.esn.mapper;
|
||||||
|
|
||||||
|
import com.zioinfo.esn.domain.TagBindingVo;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface TagBindingMapper {
|
||||||
|
List<TagBindingVo> findAll(@Param("tenantCode") String tenantCode,
|
||||||
|
@Param("storeId") Long storeId,
|
||||||
|
@Param("bindStatus") String bindStatus,
|
||||||
|
@Param("keyword") String keyword);
|
||||||
|
TagBindingVo findById(@Param("id") Long id);
|
||||||
|
TagBindingVo findByDeviceId(@Param("deviceId") String deviceId);
|
||||||
|
List<TagBindingVo> findByProductCode(@Param("tenantCode") String tenantCode,
|
||||||
|
@Param("storeId") Long storeId,
|
||||||
|
@Param("productCode") String productCode);
|
||||||
|
int insert(TagBindingVo vo);
|
||||||
|
int update(TagBindingVo vo);
|
||||||
|
int unbind(@Param("id") Long id);
|
||||||
|
int delete(@Param("id") Long id);
|
||||||
|
long countAll(@Param("tenantCode") String tenantCode);
|
||||||
|
long countActive(@Param("tenantCode") String tenantCode);
|
||||||
|
}
|
||||||
@ -0,0 +1,24 @@
|
|||||||
|
package com.zioinfo.esn.mapper;
|
||||||
|
|
||||||
|
import com.zioinfo.esn.domain.UpdateQueueVo;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Mapper
|
||||||
|
public interface UpdateQueueMapper {
|
||||||
|
List<UpdateQueueVo> findAll(@Param("tenantCode") String tenantCode,
|
||||||
|
@Param("storeId") Long storeId,
|
||||||
|
@Param("status") String status);
|
||||||
|
List<UpdateQueueVo> findPending(@Param("tenantCode") String tenantCode,
|
||||||
|
@Param("limit") int limit);
|
||||||
|
UpdateQueueVo findById(@Param("id") Long id);
|
||||||
|
int insert(UpdateQueueVo vo);
|
||||||
|
int updateStatus(@Param("id") Long id, @Param("status") String status,
|
||||||
|
@Param("errorMessage") String errorMessage);
|
||||||
|
int confirmByDeviceId(@Param("deviceId") String deviceId);
|
||||||
|
int incrementRetry(@Param("id") Long id);
|
||||||
|
long countPending(@Param("tenantCode") String tenantCode);
|
||||||
|
long countFailed(@Param("tenantCode") String tenantCode);
|
||||||
|
long countByStatus(@Param("tenantCode") String tenantCode, @Param("status") String status);
|
||||||
|
}
|
||||||
@ -3,13 +3,17 @@ package com.zioinfo.esn.service;
|
|||||||
import com.zioinfo.esn.domain.PosCvtVo;
|
import com.zioinfo.esn.domain.PosCvtVo;
|
||||||
import com.zioinfo.esn.mapper.PosCvtMapper;
|
import com.zioinfo.esn.mapper.PosCvtMapper;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class PosCvtService {
|
public class PosCvtService {
|
||||||
private final PosCvtMapper mapper;
|
private final PosCvtMapper mapper;
|
||||||
|
private final TagBindingService tagBindingService;
|
||||||
|
|
||||||
public List<PosCvtVo> list(String tenantCode, Long storeId, String status, String keyword) {
|
public List<PosCvtVo> list(String tenantCode, Long storeId, String status, String keyword) {
|
||||||
return mapper.findAll(tenantCode, storeId, status, keyword);
|
return mapper.findAll(tenantCode, storeId, status, keyword);
|
||||||
@ -17,8 +21,24 @@ public class PosCvtService {
|
|||||||
|
|
||||||
public PosCvtVo get(Long id) { return mapper.findById(id); }
|
public PosCvtVo get(Long id) { return mapper.findById(id); }
|
||||||
|
|
||||||
public void process(Long id) {
|
/**
|
||||||
|
* POS 가격변경 처리 → 바인딩된 e-paper 태그에 업데이트 큐 자동 생성
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int process(Long id) {
|
||||||
|
PosCvtVo pos = mapper.findById(id);
|
||||||
|
if (pos == null) return 0;
|
||||||
|
|
||||||
mapper.updateStatus(id, "PROCESSED", null);
|
mapper.updateStatus(id, "PROCESSED", null);
|
||||||
|
|
||||||
|
// 이 상품에 바인딩된 e-paper 태그들에 가격 업데이트 큐 생성
|
||||||
|
int queued = tagBindingService.enqueueByProductCode(
|
||||||
|
pos.getTenantCode(), pos.getStoreId(),
|
||||||
|
pos.getProductCode(), pos.getPrice(), pos.getSalePrice(), id);
|
||||||
|
|
||||||
|
log.info("POS 처리 완료: posCvtId={}, product={}, 큐생성={}개",
|
||||||
|
id, pos.getProductCode(), queued);
|
||||||
|
return queued;
|
||||||
}
|
}
|
||||||
|
|
||||||
public void markError(Long id, String errorMessage) {
|
public void markError(Long id, String errorMessage) {
|
||||||
|
|||||||
@ -0,0 +1,70 @@
|
|||||||
|
package com.zioinfo.esn.service;
|
||||||
|
|
||||||
|
import com.zioinfo.esn.domain.TagBindingVo;
|
||||||
|
import com.zioinfo.esn.domain.UpdateQueueVo;
|
||||||
|
import com.zioinfo.esn.mapper.TagBindingMapper;
|
||||||
|
import com.zioinfo.esn.mapper.UpdateQueueMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class TagBindingService {
|
||||||
|
private final TagBindingMapper bindingMapper;
|
||||||
|
private final UpdateQueueMapper queueMapper;
|
||||||
|
|
||||||
|
public List<TagBindingVo> list(String tenantCode, Long storeId,
|
||||||
|
String bindStatus, String keyword) {
|
||||||
|
return bindingMapper.findAll(tenantCode, storeId, bindStatus, keyword);
|
||||||
|
}
|
||||||
|
|
||||||
|
public TagBindingVo get(Long id) { return bindingMapper.findById(id); }
|
||||||
|
|
||||||
|
public TagBindingVo getByDeviceId(String deviceId) {
|
||||||
|
return bindingMapper.findByDeviceId(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public TagBindingVo bind(TagBindingVo vo) {
|
||||||
|
// 기존 바인딩이 있으면 해제 후 재바인딩
|
||||||
|
TagBindingVo existing = bindingMapper.findByDeviceId(vo.getDeviceId());
|
||||||
|
if (existing != null) {
|
||||||
|
bindingMapper.unbind(existing.getId());
|
||||||
|
}
|
||||||
|
bindingMapper.insert(vo);
|
||||||
|
return bindingMapper.findById(vo.getId());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void unbind(Long id) {
|
||||||
|
bindingMapper.unbind(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 가격 변경 시 해당 상품에 바인딩된 모든 태그에 업데이트 큐 생성
|
||||||
|
* POS 가격변경 → 이 메서드 → e-paper 태그들에게 전파
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public int enqueueByProductCode(String tenantCode, Long storeId,
|
||||||
|
String productCode, java.math.BigDecimal newPrice,
|
||||||
|
java.math.BigDecimal newSalePrice, Long posCvtId) {
|
||||||
|
List<TagBindingVo> bindings = bindingMapper.findByProductCode(tenantCode, storeId, productCode);
|
||||||
|
for (TagBindingVo b : bindings) {
|
||||||
|
UpdateQueueVo q = new UpdateQueueVo();
|
||||||
|
q.setTenantCode(tenantCode);
|
||||||
|
q.setStoreId(storeId);
|
||||||
|
q.setBindingId(b.getId());
|
||||||
|
q.setDeviceId(b.getDeviceId());
|
||||||
|
q.setProductCode(productCode);
|
||||||
|
q.setNewPrice(newPrice);
|
||||||
|
q.setNewSalePrice(newSalePrice);
|
||||||
|
q.setTemplateCode(b.getTemplateCode());
|
||||||
|
q.setPriority(5);
|
||||||
|
q.setPosCvtId(posCvtId);
|
||||||
|
queueMapper.insert(q);
|
||||||
|
}
|
||||||
|
return bindings.size();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,60 @@
|
|||||||
|
package com.zioinfo.esn.service;
|
||||||
|
|
||||||
|
import com.zioinfo.esn.domain.UpdateQueueVo;
|
||||||
|
import com.zioinfo.esn.mapper.UpdateQueueMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UpdateQueueService {
|
||||||
|
private final UpdateQueueMapper mapper;
|
||||||
|
|
||||||
|
public List<UpdateQueueVo> list(String tenantCode, Long storeId, String status) {
|
||||||
|
return mapper.findAll(tenantCode, storeId, status);
|
||||||
|
}
|
||||||
|
|
||||||
|
public UpdateQueueVo get(Long id) { return mapper.findById(id); }
|
||||||
|
|
||||||
|
/** Gateway 폴링: PENDING 큐 N건 반환 후 SENDING으로 전환 */
|
||||||
|
@Transactional
|
||||||
|
public List<UpdateQueueVo> pollPending(String tenantCode, int limit) {
|
||||||
|
List<UpdateQueueVo> items = mapper.findPending(tenantCode, limit);
|
||||||
|
for (UpdateQueueVo q : items) {
|
||||||
|
mapper.updateStatus(q.getId(), "SENDING", null);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gateway → 태그 전송 완료 */
|
||||||
|
@Transactional
|
||||||
|
public void markSent(Long id) {
|
||||||
|
mapper.updateStatus(id, "SENT", null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 태그 확인 응답 수신 (device_id 기반) */
|
||||||
|
@Transactional
|
||||||
|
public void confirm(String deviceId) {
|
||||||
|
mapper.confirmByDeviceId(deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 전송 실패 처리 — max_retry 초과 시 FAILED */
|
||||||
|
@Transactional
|
||||||
|
public void markFailed(Long id, String errorMessage) {
|
||||||
|
UpdateQueueVo q = mapper.findById(id);
|
||||||
|
if (q == null) return;
|
||||||
|
mapper.incrementRetry(id);
|
||||||
|
if (q.getRetryCount() + 1 >= q.getMaxRetry()) {
|
||||||
|
mapper.updateStatus(id, "FAILED", errorMessage);
|
||||||
|
} else {
|
||||||
|
mapper.updateStatus(id, "PENDING", errorMessage);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public void cancel(Long id) {
|
||||||
|
mapper.updateStatus(id, "CANCELLED", null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -261,6 +261,60 @@ SELECT 'EMART', s.id, 'P-001', '신라면', '라면/면류', 850, 800
|
|||||||
FROM esn_store s WHERE s.store_code = 'EM-001' LIMIT 1
|
FROM esn_store s WHERE s.store_code = 'EM-001' LIMIT 1
|
||||||
ON CONFLICT DO NOTHING;
|
ON CONFLICT DO NOTHING;
|
||||||
|
|
||||||
|
-- ── 태그 위치/상품 바인딩 ──────────────────────────────────────────────────────
|
||||||
|
-- e-paper 태그와 선반 위치 + 상품을 연결하는 핵심 테이블
|
||||||
|
CREATE TABLE IF NOT EXISTS esn_tag_binding (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
tenant_code VARCHAR(20) NOT NULL REFERENCES esn_tenant(tenant_code),
|
||||||
|
store_id BIGINT NOT NULL REFERENCES esn_store(id),
|
||||||
|
device_id VARCHAR(100) NOT NULL, -- ESL_DEVICE device_id
|
||||||
|
product_code VARCHAR(100), -- 현재 표시 중인 상품
|
||||||
|
location_code VARCHAR(100), -- 선반 위치코드 (예: A01-03, 통로A-1단)
|
||||||
|
template_code VARCHAR(50), -- 적용 템플릿
|
||||||
|
display_data TEXT, -- 현재 화면에 표시된 데이터 (JSON)
|
||||||
|
last_price NUMERIC(12,2), -- 마지막으로 전송된 가격
|
||||||
|
last_updated_at TIMESTAMP, -- 마지막 화면 갱신 시각
|
||||||
|
bind_status VARCHAR(20) DEFAULT 'ACTIVE', -- ACTIVE, UNBOUND, ERROR
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
UNIQUE (device_id)
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_binding_store ON esn_tag_binding(store_id, bind_status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_binding_product ON esn_tag_binding(tenant_code, product_code);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_binding_loc ON esn_tag_binding(store_id, location_code);
|
||||||
|
|
||||||
|
-- 샘플 바인딩 (이마트 강남점 ESL-EM-001-0001 ↔ 신라면)
|
||||||
|
INSERT INTO esn_tag_binding (tenant_code, store_id, device_id, product_code, location_code, template_code, last_price)
|
||||||
|
SELECT 'EMART', s.id, 'ESL-EM-001-0001', 'P-001', 'A01-03', 'EM-PRICE-STD', 850
|
||||||
|
FROM esn_store s WHERE s.store_code = 'EM-001' LIMIT 1
|
||||||
|
ON CONFLICT (device_id) DO NOTHING;
|
||||||
|
|
||||||
|
-- ── 가격 업데이트 전파 큐 ────────────────────────────────────────────────────────
|
||||||
|
-- POS 가격 변경이 e-paper 태그에 도달하는 전 과정을 추적
|
||||||
|
CREATE TABLE IF NOT EXISTS esn_update_queue (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
tenant_code VARCHAR(20) NOT NULL REFERENCES esn_tenant(tenant_code),
|
||||||
|
store_id BIGINT REFERENCES esn_store(id),
|
||||||
|
binding_id BIGINT REFERENCES esn_tag_binding(id),
|
||||||
|
device_id VARCHAR(100) NOT NULL,
|
||||||
|
product_code VARCHAR(100),
|
||||||
|
new_price NUMERIC(12,2),
|
||||||
|
new_sale_price NUMERIC(12,2),
|
||||||
|
template_code VARCHAR(50),
|
||||||
|
render_data TEXT, -- 렌더링된 화면 데이터 (JSON)
|
||||||
|
priority INTEGER DEFAULT 5, -- 1(긴급) ~ 10(일반)
|
||||||
|
status VARCHAR(20) DEFAULT 'PENDING', -- PENDING, SENDING, SENT, CONFIRMED, FAILED, CANCELLED
|
||||||
|
retry_count INTEGER DEFAULT 0,
|
||||||
|
max_retry INTEGER DEFAULT 3,
|
||||||
|
error_message TEXT,
|
||||||
|
sent_at TIMESTAMP,
|
||||||
|
confirmed_at TIMESTAMP,
|
||||||
|
pos_cvt_id BIGINT REFERENCES esn_pos_cvt(id),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_queue_device ON esn_update_queue(device_id, status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_queue_store ON esn_update_queue(store_id, status, created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_queue_pend ON esn_update_queue(status, priority, created_at) WHERE status = 'PENDING';
|
||||||
|
|
||||||
-- ── 감사 로그 ─────────────────────────────────────────────────────────────────
|
-- ── 감사 로그 ─────────────────────────────────────────────────────────────────
|
||||||
CREATE TABLE IF NOT EXISTS esn_audit_log (
|
CREATE TABLE IF NOT EXISTS esn_audit_log (
|
||||||
id BIGSERIAL PRIMARY KEY,
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
|||||||
139
backend/src/main/resources/mapper/TagBindingMapper.xml
Normal file
139
backend/src/main/resources/mapper/TagBindingMapper.xml
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
<?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.TagBindingMapper">
|
||||||
|
|
||||||
|
<resultMap id="bindingMap" type="com.zioinfo.esn.domain.TagBindingVo">
|
||||||
|
<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="deviceId" column="device_id"/>
|
||||||
|
<result property="productCode" column="product_code"/>
|
||||||
|
<result property="productName" column="product_name"/>
|
||||||
|
<result property="locationCode" column="location_code"/>
|
||||||
|
<result property="templateCode" column="template_code"/>
|
||||||
|
<result property="displayData" column="display_data"/>
|
||||||
|
<result property="lastPrice" column="last_price"/>
|
||||||
|
<result property="lastUpdatedAt" column="last_updated_at"/>
|
||||||
|
<result property="bindStatus" column="bind_status"/>
|
||||||
|
<result property="deviceStatus" column="device_status"/>
|
||||||
|
<result property="batteryLevel" column="battery_level"/>
|
||||||
|
<result property="signalStrength" column="signal_strength"/>
|
||||||
|
<result property="createdAt" column="created_at"/>
|
||||||
|
</resultMap>
|
||||||
|
|
||||||
|
<select id="findAll" resultMap="bindingMap">
|
||||||
|
SELECT b.id, b.tenant_code, b.store_id, s.store_name,
|
||||||
|
b.device_id, b.product_code, p.product_name,
|
||||||
|
b.location_code, b.template_code, b.display_data,
|
||||||
|
b.last_price, b.last_updated_at, b.bind_status,
|
||||||
|
h.status AS device_status, h.battery_level, h.signal_strength,
|
||||||
|
b.created_at
|
||||||
|
FROM esn_tag_binding b
|
||||||
|
LEFT JOIN esn_store s ON b.store_id = s.id
|
||||||
|
LEFT JOIN esn_product p ON b.store_id = p.store_id AND b.product_code = p.product_code
|
||||||
|
LEFT JOIN esn_hcore_device h ON b.device_id = h.device_id
|
||||||
|
<where>
|
||||||
|
<if test="tenantCode != null and tenantCode != ''">AND b.tenant_code = #{tenantCode}</if>
|
||||||
|
<if test="storeId != null">AND b.store_id = #{storeId}</if>
|
||||||
|
<if test="bindStatus != null and bindStatus != ''">AND b.bind_status = #{bindStatus}</if>
|
||||||
|
<if test="keyword != null and keyword != ''">
|
||||||
|
AND (b.device_id ILIKE '%'||#{keyword}||'%'
|
||||||
|
OR b.product_code ILIKE '%'||#{keyword}||'%'
|
||||||
|
OR b.location_code ILIKE '%'||#{keyword}||'%'
|
||||||
|
OR p.product_name ILIKE '%'||#{keyword}||'%')
|
||||||
|
</if>
|
||||||
|
</where>
|
||||||
|
ORDER BY b.store_id, b.location_code
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="findById" resultMap="bindingMap">
|
||||||
|
SELECT b.id, b.tenant_code, b.store_id, s.store_name,
|
||||||
|
b.device_id, b.product_code, p.product_name,
|
||||||
|
b.location_code, b.template_code, b.display_data,
|
||||||
|
b.last_price, b.last_updated_at, b.bind_status,
|
||||||
|
h.status AS device_status, h.battery_level, h.signal_strength,
|
||||||
|
b.created_at
|
||||||
|
FROM esn_tag_binding b
|
||||||
|
LEFT JOIN esn_store s ON b.store_id = s.id
|
||||||
|
LEFT JOIN esn_product p ON b.store_id = p.store_id AND b.product_code = p.product_code
|
||||||
|
LEFT JOIN esn_hcore_device h ON b.device_id = h.device_id
|
||||||
|
WHERE b.id = #{id}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="findByDeviceId" resultMap="bindingMap">
|
||||||
|
SELECT b.id, b.tenant_code, b.store_id, s.store_name,
|
||||||
|
b.device_id, b.product_code, p.product_name,
|
||||||
|
b.location_code, b.template_code, b.display_data,
|
||||||
|
b.last_price, b.last_updated_at, b.bind_status,
|
||||||
|
h.status AS device_status, h.battery_level, h.signal_strength,
|
||||||
|
b.created_at
|
||||||
|
FROM esn_tag_binding b
|
||||||
|
LEFT JOIN esn_store s ON b.store_id = s.id
|
||||||
|
LEFT JOIN esn_product p ON b.store_id = p.store_id AND b.product_code = p.product_code
|
||||||
|
LEFT JOIN esn_hcore_device h ON b.device_id = h.device_id
|
||||||
|
WHERE b.device_id = #{deviceId}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="findByProductCode" resultMap="bindingMap">
|
||||||
|
SELECT b.id, b.tenant_code, b.store_id, s.store_name,
|
||||||
|
b.device_id, b.product_code, p.product_name,
|
||||||
|
b.location_code, b.template_code, b.display_data,
|
||||||
|
b.last_price, b.last_updated_at, b.bind_status,
|
||||||
|
h.status AS device_status, h.battery_level, h.signal_strength,
|
||||||
|
b.created_at
|
||||||
|
FROM esn_tag_binding b
|
||||||
|
LEFT JOIN esn_store s ON b.store_id = s.id
|
||||||
|
LEFT JOIN esn_product p ON b.store_id = p.store_id AND b.product_code = p.product_code
|
||||||
|
LEFT JOIN esn_hcore_device h ON b.device_id = h.device_id
|
||||||
|
WHERE b.tenant_code = #{tenantCode}
|
||||||
|
AND b.store_id = #{storeId}
|
||||||
|
AND b.product_code = #{productCode}
|
||||||
|
AND b.bind_status = 'ACTIVE'
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
|
||||||
|
INSERT INTO esn_tag_binding
|
||||||
|
(tenant_code, store_id, device_id, product_code, location_code,
|
||||||
|
template_code, last_price, bind_status)
|
||||||
|
VALUES
|
||||||
|
(#{tenantCode}, #{storeId}, #{deviceId}, #{productCode}, #{locationCode},
|
||||||
|
#{templateCode}, #{lastPrice}, 'ACTIVE')
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<update id="update">
|
||||||
|
UPDATE esn_tag_binding SET
|
||||||
|
product_code = #{productCode},
|
||||||
|
location_code = #{locationCode},
|
||||||
|
template_code = #{templateCode},
|
||||||
|
display_data = #{displayData},
|
||||||
|
last_price = #{lastPrice},
|
||||||
|
last_updated_at = NOW(),
|
||||||
|
bind_status = #{bindStatus}
|
||||||
|
WHERE id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="unbind">
|
||||||
|
UPDATE esn_tag_binding SET
|
||||||
|
bind_status = 'UNBOUND',
|
||||||
|
product_code = NULL,
|
||||||
|
last_updated_at = NOW()
|
||||||
|
WHERE id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<delete id="delete">DELETE FROM esn_tag_binding WHERE id = #{id}</delete>
|
||||||
|
|
||||||
|
<select id="countAll" resultType="long">
|
||||||
|
SELECT COUNT(*) FROM esn_tag_binding
|
||||||
|
<where>
|
||||||
|
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
|
||||||
|
</where>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="countActive" resultType="long">
|
||||||
|
SELECT COUNT(*) FROM esn_tag_binding WHERE bind_status = 'ACTIVE'
|
||||||
|
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
131
backend/src/main/resources/mapper/UpdateQueueMapper.xml
Normal file
131
backend/src/main/resources/mapper/UpdateQueueMapper.xml
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
<?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.UpdateQueueMapper">
|
||||||
|
|
||||||
|
<resultMap id="queueMap" type="com.zioinfo.esn.domain.UpdateQueueVo">
|
||||||
|
<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="bindingId" column="binding_id"/>
|
||||||
|
<result property="deviceId" column="device_id"/>
|
||||||
|
<result property="locationCode" column="location_code"/>
|
||||||
|
<result property="productCode" column="product_code"/>
|
||||||
|
<result property="productName" column="product_name"/>
|
||||||
|
<result property="newPrice" column="new_price"/>
|
||||||
|
<result property="newSalePrice" column="new_sale_price"/>
|
||||||
|
<result property="templateCode" column="template_code"/>
|
||||||
|
<result property="renderData" column="render_data"/>
|
||||||
|
<result property="priority" column="priority"/>
|
||||||
|
<result property="status" column="status"/>
|
||||||
|
<result property="retryCount" column="retry_count"/>
|
||||||
|
<result property="maxRetry" column="max_retry"/>
|
||||||
|
<result property="errorMessage" column="error_message"/>
|
||||||
|
<result property="posCvtId" column="pos_cvt_id"/>
|
||||||
|
<result property="sentAt" column="sent_at"/>
|
||||||
|
<result property="confirmedAt" column="confirmed_at"/>
|
||||||
|
<result property="createdAt" column="created_at"/>
|
||||||
|
</resultMap>
|
||||||
|
|
||||||
|
<select id="findAll" resultMap="queueMap">
|
||||||
|
SELECT q.id, q.tenant_code, q.store_id, s.store_name,
|
||||||
|
q.binding_id, q.device_id, b.location_code,
|
||||||
|
q.product_code, p.product_name,
|
||||||
|
q.new_price, q.new_sale_price, q.template_code, q.render_data,
|
||||||
|
q.priority, q.status, q.retry_count, q.max_retry, q.error_message,
|
||||||
|
q.pos_cvt_id, q.sent_at, q.confirmed_at, q.created_at
|
||||||
|
FROM esn_update_queue q
|
||||||
|
LEFT JOIN esn_store s ON q.store_id = s.id
|
||||||
|
LEFT JOIN esn_tag_binding b ON q.binding_id = b.id
|
||||||
|
LEFT JOIN esn_product p ON q.store_id = p.store_id AND q.product_code = p.product_code
|
||||||
|
<where>
|
||||||
|
<if test="tenantCode != null and tenantCode != ''">AND q.tenant_code = #{tenantCode}</if>
|
||||||
|
<if test="storeId != null">AND q.store_id = #{storeId}</if>
|
||||||
|
<if test="status != null and status != ''">AND q.status = #{status}</if>
|
||||||
|
</where>
|
||||||
|
ORDER BY q.priority ASC, q.created_at ASC
|
||||||
|
LIMIT 200
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<!-- Gateway가 전송할 PENDING 목록 (우선순위·시간 순) -->
|
||||||
|
<select id="findPending" resultMap="queueMap">
|
||||||
|
SELECT q.id, q.tenant_code, q.store_id, s.store_name,
|
||||||
|
q.binding_id, q.device_id, b.location_code,
|
||||||
|
q.product_code, p.product_name,
|
||||||
|
q.new_price, q.new_sale_price, q.template_code, q.render_data,
|
||||||
|
q.priority, q.status, q.retry_count, q.max_retry, q.error_message,
|
||||||
|
q.pos_cvt_id, q.sent_at, q.confirmed_at, q.created_at
|
||||||
|
FROM esn_update_queue q
|
||||||
|
LEFT JOIN esn_store s ON q.store_id = s.id
|
||||||
|
LEFT JOIN esn_tag_binding b ON q.binding_id = b.id
|
||||||
|
LEFT JOIN esn_product p ON q.store_id = p.store_id AND q.product_code = p.product_code
|
||||||
|
WHERE q.status = 'PENDING'
|
||||||
|
AND q.retry_count < q.max_retry
|
||||||
|
<if test="tenantCode != null and tenantCode != ''">AND q.tenant_code = #{tenantCode}</if>
|
||||||
|
ORDER BY q.priority ASC, q.created_at ASC
|
||||||
|
LIMIT #{limit}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="findById" resultMap="queueMap">
|
||||||
|
SELECT q.id, q.tenant_code, q.store_id, s.store_name,
|
||||||
|
q.binding_id, q.device_id, b.location_code,
|
||||||
|
q.product_code, p.product_name,
|
||||||
|
q.new_price, q.new_sale_price, q.template_code, q.render_data,
|
||||||
|
q.priority, q.status, q.retry_count, q.max_retry, q.error_message,
|
||||||
|
q.pos_cvt_id, q.sent_at, q.confirmed_at, q.created_at
|
||||||
|
FROM esn_update_queue q
|
||||||
|
LEFT JOIN esn_store s ON q.store_id = s.id
|
||||||
|
LEFT JOIN esn_tag_binding b ON q.binding_id = b.id
|
||||||
|
LEFT JOIN esn_product p ON q.store_id = p.store_id AND q.product_code = p.product_code
|
||||||
|
WHERE q.id = #{id}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<insert id="insert" useGeneratedKeys="true" keyProperty="id">
|
||||||
|
INSERT INTO esn_update_queue
|
||||||
|
(tenant_code, store_id, binding_id, device_id, product_code,
|
||||||
|
new_price, new_sale_price, template_code, priority, status, pos_cvt_id)
|
||||||
|
VALUES
|
||||||
|
(#{tenantCode}, #{storeId}, #{bindingId}, #{deviceId}, #{productCode},
|
||||||
|
#{newPrice}, #{newSalePrice}, #{templateCode}, #{priority}, 'PENDING', #{posCvtId})
|
||||||
|
</insert>
|
||||||
|
|
||||||
|
<update id="updateStatus">
|
||||||
|
UPDATE esn_update_queue SET
|
||||||
|
status = #{status},
|
||||||
|
error_message = #{errorMessage},
|
||||||
|
sent_at = CASE WHEN #{status} = 'SENT' THEN NOW() ELSE sent_at END,
|
||||||
|
confirmed_at = CASE WHEN #{status} = 'CONFIRMED' THEN NOW() ELSE confirmed_at END
|
||||||
|
WHERE id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<!-- 태그가 확인 응답 시 — device_id로 SENT → CONFIRMED -->
|
||||||
|
<update id="confirmByDeviceId">
|
||||||
|
UPDATE esn_update_queue SET
|
||||||
|
status = 'CONFIRMED',
|
||||||
|
confirmed_at = NOW()
|
||||||
|
WHERE device_id = #{deviceId}
|
||||||
|
AND status IN ('SENDING', 'SENT')
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<update id="incrementRetry">
|
||||||
|
UPDATE esn_update_queue SET retry_count = retry_count + 1
|
||||||
|
WHERE id = #{id}
|
||||||
|
</update>
|
||||||
|
|
||||||
|
<select id="countPending" resultType="long">
|
||||||
|
SELECT COUNT(*) FROM esn_update_queue WHERE status = 'PENDING'
|
||||||
|
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="countFailed" resultType="long">
|
||||||
|
SELECT COUNT(*) FROM esn_update_queue WHERE status = 'FAILED'
|
||||||
|
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="countByStatus" resultType="long">
|
||||||
|
SELECT COUNT(*) FROM esn_update_queue WHERE status = #{status}
|
||||||
|
<if test="tenantCode != null and tenantCode != ''">AND tenant_code = #{tenantCode}</if>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
@ -14,6 +14,8 @@ import UserList from './pages/UserList'
|
|||||||
import ProductList from './pages/ProductList'
|
import ProductList from './pages/ProductList'
|
||||||
import TenantAdmin from './pages/TenantAdmin'
|
import TenantAdmin from './pages/TenantAdmin'
|
||||||
import AiAnalysis from './pages/AiAnalysis'
|
import AiAnalysis from './pages/AiAnalysis'
|
||||||
|
import TagBindingList from './pages/TagBindingList'
|
||||||
|
import UpdateQueueList from './pages/UpdateQueueList'
|
||||||
|
|
||||||
const qc = new QueryClient()
|
const qc = new QueryClient()
|
||||||
|
|
||||||
@ -28,6 +30,8 @@ export default function App() {
|
|||||||
<Route path="/stores" element={<StoreList />} />
|
<Route path="/stores" element={<StoreList />} />
|
||||||
<Route path="/templates" element={<TemplateList />} />
|
<Route path="/templates" element={<TemplateList />} />
|
||||||
<Route path="/pos-cvt" element={<PosCvtList />} />
|
<Route path="/pos-cvt" element={<PosCvtList />} />
|
||||||
|
<Route path="/tag-binding" element={<TagBindingList />} />
|
||||||
|
<Route path="/update-queue" element={<UpdateQueueList />} />
|
||||||
<Route path="/alarms" element={<AlarmList />} />
|
<Route path="/alarms" element={<AlarmList />} />
|
||||||
<Route path="/hcore" element={<HCoreStatus />} />
|
<Route path="/hcore" element={<HCoreStatus />} />
|
||||||
<Route path="/works" element={<WorkHistory />} />
|
<Route path="/works" element={<WorkHistory />} />
|
||||||
|
|||||||
@ -1,14 +1,17 @@
|
|||||||
import { NavLink } from 'react-router-dom'
|
import { NavLink } from 'react-router-dom'
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Store, FileText, RefreshCw,
|
LayoutDashboard, Store, FileText, RefreshCw,
|
||||||
Bell, Cpu, ClipboardList, Zap, Users, Package, Building2, Brain
|
Bell, Cpu, ClipboardList, Zap, Users, Package, Building2, Brain,
|
||||||
|
Link2, ListOrdered
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const nav = [
|
const nav = [
|
||||||
{ to: '/dashboard', icon: LayoutDashboard, label: '대시보드' },
|
{ to: '/dashboard', icon: LayoutDashboard, label: '대시보드' },
|
||||||
{ to: '/stores', icon: Store, label: '매장 관리' },
|
{ to: '/stores', icon: Store, label: '매장 관리' },
|
||||||
{ to: '/templates', icon: FileText, label: 'ESL 템플릿' },
|
{ to: '/templates', icon: FileText, label: 'ESL 템플릿' },
|
||||||
{ to: '/pos-cvt', icon: RefreshCw, label: 'POS 변환' },
|
{ to: '/pos-cvt', icon: RefreshCw, label: 'POS 가격변환' },
|
||||||
|
{ to: '/tag-binding', icon: Link2, label: '태그 바인딩' },
|
||||||
|
{ to: '/update-queue',icon: ListOrdered, label: '업데이트 큐' },
|
||||||
{ to: '/alarms', icon: Bell, label: '알람 관리' },
|
{ to: '/alarms', icon: Bell, label: '알람 관리' },
|
||||||
{ to: '/hcore', icon: Cpu, label: 'HCore 장치' },
|
{ to: '/hcore', icon: Cpu, label: 'HCore 장치' },
|
||||||
{ to: '/works', icon: ClipboardList, label: '작업 이력' },
|
{ to: '/works', icon: ClipboardList, label: '작업 이력' },
|
||||||
|
|||||||
193
frontend/src/pages/TagBindingList.tsx
Normal file
193
frontend/src/pages/TagBindingList.tsx
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Link2, Link2Off, MapPin, Battery, Wifi, WifiOff, RefreshCw } from 'lucide-react'
|
||||||
|
import api from '../api/client'
|
||||||
|
|
||||||
|
interface TagBinding {
|
||||||
|
id: number; tenantCode: string; storeId: number; storeName: string
|
||||||
|
deviceId: string; productCode: string; productName: string
|
||||||
|
locationCode: string; templateCode: string; lastPrice: number
|
||||||
|
lastUpdatedAt: string; bindStatus: string
|
||||||
|
deviceStatus: string; batteryLevel: number; signalStrength: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BindForm {
|
||||||
|
tenantCode: string; storeId: string; deviceId: string
|
||||||
|
productCode: string; locationCode: string; templateCode: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const bindStatusColor: Record<string, string> = {
|
||||||
|
ACTIVE: 'text-green-400 bg-green-500/10 border-green-500/30',
|
||||||
|
UNBOUND: 'text-gray-400 bg-gray-500/10 border-gray-500/30',
|
||||||
|
ERROR: 'text-red-400 bg-red-500/10 border-red-500/30',
|
||||||
|
}
|
||||||
|
|
||||||
|
const deviceStatusIcon = (s: string) =>
|
||||||
|
s === 'ONLINE' ? <Wifi size={13} className="text-green-400" /> : <WifiOff size={13} className="text-red-400" />
|
||||||
|
|
||||||
|
export default function TagBindingList() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [tenantCode, setTenantCode] = useState('')
|
||||||
|
const [storeId, setStoreId] = useState('')
|
||||||
|
const [keyword, setKeyword] = useState('')
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [form, setForm] = useState<BindForm>({
|
||||||
|
tenantCode:'', storeId:'', deviceId:'', productCode:'', locationCode:'', templateCode:'EM-PRICE-STD'
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: bindings = [], isLoading } = useQuery<TagBinding[]>({
|
||||||
|
queryKey: ['tag-binding', tenantCode, storeId, keyword],
|
||||||
|
queryFn: () => api.get('/tag-binding', { params: {
|
||||||
|
tenantCode: tenantCode || undefined,
|
||||||
|
storeId: storeId || undefined,
|
||||||
|
keyword: keyword || undefined,
|
||||||
|
bindStatus: 'ACTIVE',
|
||||||
|
}}).then(r => r.data.data),
|
||||||
|
refetchInterval: 20000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const bindMut = useMutation({
|
||||||
|
mutationFn: (data: BindForm) => api.post('/tag-binding', {
|
||||||
|
...data,
|
||||||
|
storeId: Number(data.storeId),
|
||||||
|
}),
|
||||||
|
onSuccess: () => { qc.invalidateQueries({ queryKey: ['tag-binding'] }); setShowForm(false) },
|
||||||
|
})
|
||||||
|
|
||||||
|
const unbindMut = useMutation({
|
||||||
|
mutationFn: (id: number) => api.put(`/tag-binding/${id}/unbind`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['tag-binding'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
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>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(v => !v)}
|
||||||
|
className="flex items-center gap-2 px-3 py-1.5 bg-brand text-white rounded text-sm"
|
||||||
|
>
|
||||||
|
<Link2 size={14} /> 바인딩 추가
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 바인딩 추가 폼 */}
|
||||||
|
{showForm && (
|
||||||
|
<div className="bg-panel border border-edge rounded-lg p-4 space-y-3">
|
||||||
|
<h2 className="text-sm font-medium text-white">e-paper 태그 → 상품 바인딩</h2>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{[
|
||||||
|
{ key:'tenantCode', label:'테넌트', ph:'EMART' },
|
||||||
|
{ key:'storeId', label:'매장ID', ph:'1' },
|
||||||
|
{ key:'deviceId', label:'태그 Device ID', ph:'ESL-EM-001-0001' },
|
||||||
|
{ key:'productCode', label:'상품코드', ph:'P-001' },
|
||||||
|
{ key:'locationCode', label:'선반 위치코드', ph:'A01-03' },
|
||||||
|
{ key:'templateCode', label:'템플릿', ph:'EM-PRICE-STD' },
|
||||||
|
].map(({ key, label, ph }) => (
|
||||||
|
<div key={key}>
|
||||||
|
<label className="text-xs text-gray-400 mb-1 block">{label}</label>
|
||||||
|
<input
|
||||||
|
value={form[key as keyof BindForm]}
|
||||||
|
onChange={e => setForm(f => ({ ...f, [key]: e.target.value }))}
|
||||||
|
placeholder={ph}
|
||||||
|
className="w-full bg-edge border border-gray-600 rounded px-2 py-1.5 text-sm text-white"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => bindMut.mutate(form)}
|
||||||
|
className="px-3 py-1.5 bg-brand text-white rounded text-sm"
|
||||||
|
>바인딩 저장</button>
|
||||||
|
<button onClick={() => setShowForm(false)} className="px-3 py-1.5 bg-edge text-gray-300 rounded text-sm">취소</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 필터 */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
{[
|
||||||
|
{ val: tenantCode, set: setTenantCode, ph: '테넌트 필터' },
|
||||||
|
{ val: keyword, set: setKeyword, ph: '태그ID / 상품코드 / 위치 검색' },
|
||||||
|
].map(({ val, set, ph }, i) => (
|
||||||
|
<input key={i} value={val} onChange={e => set(e.target.value)} placeholder={ph}
|
||||||
|
className="bg-panel border border-edge rounded px-3 py-1.5 text-sm text-gray-300 w-64"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 태그 목록 */}
|
||||||
|
<div className="grid grid-cols-1 gap-3">
|
||||||
|
{bindings.map(b => (
|
||||||
|
<div key={b.id}
|
||||||
|
className={`bg-panel border rounded-lg p-4 flex items-center justify-between
|
||||||
|
${b.deviceStatus === 'ONLINE' ? 'border-green-500/20' : 'border-red-500/20'}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{/* 태그 상태 */}
|
||||||
|
<div className="flex flex-col items-center gap-1 w-16">
|
||||||
|
{deviceStatusIcon(b.deviceStatus)}
|
||||||
|
{b.batteryLevel !== null && (
|
||||||
|
<div className="flex items-center gap-1 text-xs text-gray-400">
|
||||||
|
<Battery size={11} />
|
||||||
|
<span className={b.batteryLevel < 20 ? 'text-red-400' : ''}>{b.batteryLevel}%</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 태그 정보 */}
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-mono text-brand">{b.deviceId}</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1">
|
||||||
|
<MapPin size={11} className="text-gray-500" />
|
||||||
|
<span className="text-xs text-gray-400">{b.locationCode || '위치 미지정'}</span>
|
||||||
|
<span className="text-xs text-gray-600">|</span>
|
||||||
|
<span className="text-xs text-gray-300">{b.storeName}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상품 정보 */}
|
||||||
|
<div className="ml-4 pl-4 border-l border-edge">
|
||||||
|
<div className="text-sm text-white">{b.productName || b.productCode}</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-0.5">
|
||||||
|
현재가: <span className="text-brand font-mono">
|
||||||
|
{b.lastPrice ? b.lastPrice.toLocaleString() + '원' : '미전송'}
|
||||||
|
</span>
|
||||||
|
{b.lastUpdatedAt && (
|
||||||
|
<span className="ml-2 text-gray-600">
|
||||||
|
갱신: {new Date(b.lastUpdatedAt).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">템플릿: {b.templateCode}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 상태 + 액션 */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded border ${bindStatusColor[b.bindStatus] || ''}`}>
|
||||||
|
{b.bindStatus}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => { if (confirm(`태그 ${b.deviceId} 언바인딩하겠습니까?`)) unbindMut.mutate(b.id) }}
|
||||||
|
className="p-1.5 rounded bg-edge text-gray-400 hover:text-red-400"
|
||||||
|
title="언바인딩"
|
||||||
|
>
|
||||||
|
<Link2Off size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{bindings.length === 0 && (
|
||||||
|
<div className="text-center text-gray-500 py-12">
|
||||||
|
바인딩된 태그가 없습니다. 우측 상단 "바인딩 추가"를 클릭하세요.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
154
frontend/src/pages/UpdateQueueList.tsx
Normal file
154
frontend/src/pages/UpdateQueueList.tsx
Normal file
@ -0,0 +1,154 @@
|
|||||||
|
import React, { useState } from 'react'
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { CheckCircle, XCircle, Clock, Send, AlertCircle, RefreshCw } from 'lucide-react'
|
||||||
|
import api from '../api/client'
|
||||||
|
|
||||||
|
interface QueueItem {
|
||||||
|
id: number; tenantCode: string; storeId: number; storeName: string
|
||||||
|
deviceId: string; locationCode: string; productCode: string; productName: string
|
||||||
|
newPrice: number; newSalePrice: number; templateCode: string
|
||||||
|
priority: number; status: string; retryCount: number; maxRetry: number
|
||||||
|
errorMessage: string; sentAt: string; confirmedAt: string; createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusMeta: Record<string, { icon: React.ReactNode; color: string; label: string }> = {
|
||||||
|
PENDING: { icon: <Clock size={13} />, color: 'text-yellow-400 bg-yellow-500/10 border-yellow-500/30', label: '대기' },
|
||||||
|
SENDING: { icon: <Send size={13} />, color: 'text-blue-400 bg-blue-500/10 border-blue-500/30', label: '전송중' },
|
||||||
|
SENT: { icon: <Send size={13} />, color: 'text-cyan-400 bg-cyan-500/10 border-cyan-500/30', label: '전송됨' },
|
||||||
|
CONFIRMED: { icon: <CheckCircle size={13} />, color: 'text-green-400 bg-green-500/10 border-green-500/30', label: '확인됨' },
|
||||||
|
FAILED: { icon: <XCircle size={13} />, color: 'text-red-400 bg-red-500/10 border-red-500/30', label: '실패' },
|
||||||
|
CANCELLED: { icon: <AlertCircle size={13} />, color: 'text-gray-400 bg-gray-500/10 border-gray-500/30', label: '취소됨' },
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function UpdateQueueList() {
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [tenantCode, setTenantCode] = useState('')
|
||||||
|
const [status, setStatus] = useState('')
|
||||||
|
|
||||||
|
const { data: items = [], isLoading, refetch } = useQuery<QueueItem[]>({
|
||||||
|
queryKey: ['update-queue', tenantCode, status],
|
||||||
|
queryFn: () => api.get('/update-queue', { params: {
|
||||||
|
tenantCode: tenantCode || undefined,
|
||||||
|
status: status || undefined,
|
||||||
|
}}).then(r => r.data.data),
|
||||||
|
refetchInterval: 5000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const cancelMut = useMutation({
|
||||||
|
mutationFn: (id: number) => api.put(`/update-queue/${id}/cancel`),
|
||||||
|
onSuccess: () => qc.invalidateQueries({ queryKey: ['update-queue'] }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const pendingCount = items.filter(i => i.status === 'PENDING').length
|
||||||
|
const failedCount = items.filter(i => i.status === 'FAILED').length
|
||||||
|
const confirmedCount = items.filter(i => i.status === 'CONFIRMED').length
|
||||||
|
|
||||||
|
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">e-paper 가격 업데이트 큐</h1>
|
||||||
|
<button onClick={() => refetch()} className="flex items-center gap-1.5 px-3 py-1.5 bg-edge text-gray-300 rounded text-sm">
|
||||||
|
<RefreshCw size={13} /> 새로고침
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 통계 카드 */}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
{[
|
||||||
|
{ label: '대기중', count: pendingCount, color: 'text-yellow-400', bg: 'bg-yellow-500/5 border-yellow-500/20' },
|
||||||
|
{ label: '실패', count: failedCount, color: 'text-red-400', bg: 'bg-red-500/5 border-red-500/20' },
|
||||||
|
{ label: '완료', count: confirmedCount, color: 'text-green-400', bg: 'bg-green-500/5 border-green-500/20' },
|
||||||
|
].map(({ label, count, color, bg }) => (
|
||||||
|
<div key={label} className={`border rounded-lg p-3 ${bg}`}>
|
||||||
|
<div className={`text-2xl font-bold font-mono ${color}`}>{count}</div>
|
||||||
|
<div className="text-xs text-gray-400 mt-0.5">{label}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 필터 */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<input value={tenantCode} onChange={e => setTenantCode(e.target.value)}
|
||||||
|
placeholder="테넌트 필터"
|
||||||
|
className="bg-panel border border-edge rounded px-3 py-1.5 text-sm text-gray-300 w-40"
|
||||||
|
/>
|
||||||
|
<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>
|
||||||
|
{Object.entries(statusMeta).map(([k, v]) => (
|
||||||
|
<option key={k} value={k}>{v.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 큐 목록 */}
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-edge text-xs text-gray-500">
|
||||||
|
<th className="text-left py-2 px-3">#</th>
|
||||||
|
<th className="text-left py-2 px-3">태그 ID</th>
|
||||||
|
<th className="text-left py-2 px-3">위치</th>
|
||||||
|
<th className="text-left py-2 px-3">상품</th>
|
||||||
|
<th className="text-right py-2 px-3">가격</th>
|
||||||
|
<th className="text-center py-2 px-3">우선순위</th>
|
||||||
|
<th className="text-center py-2 px-3">상태</th>
|
||||||
|
<th className="text-center py-2 px-3">재시도</th>
|
||||||
|
<th className="text-left py-2 px-3">등록 시각</th>
|
||||||
|
<th className="text-center py-2 px-3">액션</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map(item => {
|
||||||
|
const meta = statusMeta[item.status] || statusMeta['PENDING']
|
||||||
|
return (
|
||||||
|
<tr key={item.id} className="border-b border-edge/50 hover:bg-edge/30">
|
||||||
|
<td className="py-2 px-3 text-gray-500 font-mono text-xs">{item.id}</td>
|
||||||
|
<td className="py-2 px-3 text-brand font-mono text-xs">{item.deviceId}</td>
|
||||||
|
<td className="py-2 px-3 text-gray-300 text-xs">{item.locationCode || '-'}</td>
|
||||||
|
<td className="py-2 px-3 text-white text-xs">{item.productName || item.productCode}</td>
|
||||||
|
<td className="py-2 px-3 text-right font-mono text-xs">
|
||||||
|
{item.newPrice?.toLocaleString()}원
|
||||||
|
{item.newSalePrice && item.newSalePrice !== item.newPrice && (
|
||||||
|
<span className="ml-1 text-red-400">→ {item.newSalePrice.toLocaleString()}원</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-center">
|
||||||
|
<span className={`text-xs font-mono ${item.priority <= 2 ? 'text-red-400' : 'text-gray-400'}`}>
|
||||||
|
P{item.priority}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-center">
|
||||||
|
<span className={`inline-flex items-center gap-1 text-xs px-2 py-0.5 rounded border ${meta.color}`}>
|
||||||
|
{meta.icon} {meta.label}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-center text-xs text-gray-400">
|
||||||
|
{item.retryCount}/{item.maxRetry}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-xs text-gray-500">
|
||||||
|
{new Date(item.createdAt).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
<td className="py-2 px-3 text-center">
|
||||||
|
{['PENDING', 'FAILED'].includes(item.status) && (
|
||||||
|
<button
|
||||||
|
onClick={() => cancelMut.mutate(item.id)}
|
||||||
|
className="text-xs text-gray-500 hover:text-red-400 px-2 py-0.5 rounded bg-edge"
|
||||||
|
>취소</button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
{items.length === 0 && (
|
||||||
|
<div className="text-center text-gray-500 py-12">업데이트 큐가 비어있습니다.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user