대시보드, 기기에러, 계정관리 수정

This commit is contained in:
comicgum
2026-09-15 21:52:40 +09:00
parent 4a6c9d2afe
commit d1e230eb6a
32 changed files with 1984 additions and 73 deletions
@@ -195,6 +195,8 @@ public class _AG {
uri.compareTo("/api/terminal-state.do") == 0 ||
uri.compareTo("/api/sign-in.do") == 0 ||
uri.compareTo("/api/sval2.do") == 0 ||
uri.compareTo("/api/send-sms-test.do") == 0 || //TempCode: SMS integration test, remove when done
uri.compareTo("/api/add-device-error.do") == 0 ||
uri.compareTo("/api/add-transaction.do") == 0) {
return true;
}
@@ -0,0 +1,133 @@
package com.handong.smartservice.component;
import java.util.ArrayList;
import java.util.List;
/**
* Decoder for the "자판기 이상 DATA (DC = 0x03)" telegram stored in device_error.error_code.
* Spec: 카드리더기_롯데_통신전문_추가사양_할부결제_요청 문서 10.3 (5 ~ 15 byte 가변)
*
* byte 1 bit0~7 : 대표이상 / 코인메카니즘 / 지폐식별기 / 물없음 / 온수저온 / 얼음품절 / 컵품절·컵걸림 / 배수부 가득참
* byte 2 bit0~6 : System 이상 / 기타 이상 / Bucket 이상 / Door Open / 슬레이브 이상 / Reserved / Reserved
* byte 3~14 : 품절 컬럼 1 ~ 96 (byte 당 8 컬럼)
* byte 15 : 하위니블 = 품절 컬럼 97~99, 상위니블 = 컬럼 백의자리(0~9)
*/
public class DeviceErrorCode {
public static final String STATE_FAULT = "이상";
public static final String STATE_SOLD_OUT = "품절";
private static final String[] BYTE1_LABELS = {
"대표이상",
"코인메카니즘 관련 이상",
"지폐식별기 관련 이상",
"물없음",
"온수저온",
"얼음품절",
"컵품절 또는 컵걸림",
"배수부 가득참",
};
private static final String[] BYTE2_LABELS = {
"System 이상",
"기타 이상",
"Bucket 이상",
"Door Open",
"슬레이브 이상",
"Reserved(14)",
"Reserved(15)",
};
private static int[] toBytes(String error_code) {
if (error_code == null)
return new int[0];
String hex = error_code.trim();
if (hex.length() < 2 || hex.length() % 2 != 0 || hex.matches(".*[^0-9a-fA-F].*"))
return new int[0];
int[] bytes = new int[hex.length() / 2];
for (int i = 0; i < bytes.length; i++)
bytes[i] = Integer.parseInt(hex.substring(i * 2, i * 2 + 2), 16);
return bytes;
}
/** 대표이상을 제외한 단말기 이상 목록 */
public static List<String> getFaults(String error_code) {
int[] bytes = toBytes(error_code);
List<String> faults = new ArrayList<>();
if (bytes.length == 0)
return faults;
for (int bit = 1; bit < 8; bit++) {
if ((bytes[0] & (1 << bit)) != 0)
faults.add(BYTE1_LABELS[bit]);
}
if (bytes.length >= 2) {
for (int bit = 0; bit < 7; bit++) {
if ((bytes[1] & (1 << bit)) != 0)
faults.add(BYTE2_LABELS[bit]);
}
}
return faults;
}
/** 품절 컬럼번호 목록 */
public static List<Integer> getSoldOutColumns(String error_code) {
int[] bytes = toBytes(error_code);
List<Integer> columns = new ArrayList<>();
if (bytes.length == 0)
return columns;
// The last byte carries the hundreds digit of the column number in its upper nibble
int hundreds = bytes.length >= 15 ? (bytes[14] >> 4) & 0x0f : 0;
int base = hundreds * 100;
int last = Math.min(bytes.length, 14);
for (int index = 2; index < last; index++) {
for (int bit = 0; bit < 8; bit++) {
if ((bytes[index] & (1 << bit)) != 0)
columns.add(base + (index - 2) * 8 + bit + 1);
}
}
if (bytes.length >= 15) {
for (int bit = 0; bit < 3; bit++) {
if ((bytes[14] & (1 << bit)) != 0)
columns.add(base + 97 + bit);
}
}
return columns;
}
public static boolean hasRepresentative(String error_code) {
int[] bytes = toBytes(error_code);
return bytes.length > 0 && (bytes[0] & 0x01) != 0;
}
/** One line summary of the telegram, shown in the list screen. */
public static String describe(String error_code) {
List<String> parts = new ArrayList<>(getFaults(error_code));
List<Integer> columns = getSoldOutColumns(error_code);
if (columns.size() > 0) {
StringBuilder sb = new StringBuilder("품절 (컬럼 ");
for (int i = 0; i < columns.size(); i++) {
if (i > 0)
sb.append(", ");
sb.append(columns.get(i));
}
sb.append(")");
parts.add(sb.toString());
}
if (parts.isEmpty())
return hasRepresentative(error_code) ? "대표이상" : "";
return String.join(", ", parts);
}
}
@@ -24,8 +24,8 @@ public class SMSSender {
private static final Logger logger = LoggerFactory.getLogger(SMSSender.class);
// Munja114 account, issued and authorized through the Munja114 customer center
private static final String REMOTE_ID = "";
private static final String REMOTE_PASS = "";
private static final String REMOTE_ID = "handong3883";
private static final String REMOTE_PASS = "gksehd3885@";
private static final String URL_SMS = "https://www.munja114.co.kr/Remote/RemoteSms.html";
private static final String URL_LMS = "https://www.munja114.co.kr/Remote/RemoteMms.html";
@@ -56,8 +56,9 @@ class AccountController {
String user_id = body.get("user_id");
String email = body.get("email");
Long biz_group_id = _AG.toLong(body.get("biz_group_id"));
Long state = _AG.toLong(body.get("state"));
Map<String, Object> mapResult = accountService.getAccountList(offset, limit, date_start, date_end, is_excel, is_group_access, user_id, email, name, phone, biz_group_id);
Map<String, Object> mapResult = accountService.getAccountList(offset, limit, date_start, date_end, is_excel, is_group_access, user_id, email, name, phone, biz_group_id, state);
if (is_excel) {
List<Map<String, String>> list = (List<Map<String, String>>)mapResult.get("list");
@@ -0,0 +1,46 @@
package com.handong.smartservice.controller.api;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.*;
import com.handong.smartservice._AG;
import com.handong.smartservice.component.CtResponse;
import com.handong.smartservice.service.DashboardService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.Map;
@RestController
@RequestMapping("/api")
class DashboardController {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
private static final long DEFAULT_DAYS = 7L;
private static final long MAX_DAYS = 31L;
private final DashboardService dashboardService;
public DashboardController(DashboardService dashboardService) {
this.dashboardService = dashboardService;
}
@RequestMapping(value = "/get-dashboard.do", method = RequestMethod.POST)
public CtResponse getDashboard(HttpServletRequest request, HttpServletResponse response, @RequestBody(required = false) Map<String, String> body) {
logger.info("req: " + request.getRequestURI());
CtResponse result = new CtResponse();
Long days = body != null ? _AG.toLong(body.get("days")) : 0L;
if (days == null || days <= 0)
days = DEFAULT_DAYS;
if (days > MAX_DAYS)
days = MAX_DAYS;
result.put("result", dashboardService.getDashboard(days));
return result;
}
}
@@ -0,0 +1,112 @@
package com.handong.smartservice.controller.api;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.*;
import com.handong.smartservice._AG;
import com.handong.smartservice.component.CtExcelMaker;
import com.handong.smartservice.component.CtResponse;
import com.handong.smartservice.component.ErrorCode;
import com.handong.smartservice.service.DeviceErrorService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api")
class DeviceErrorController {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
private final DeviceErrorService deviceErrorService;
public DeviceErrorController(DeviceErrorService deviceErrorService) {
this.deviceErrorService = deviceErrorService;
}
@RequestMapping(value = "/get-device-error.do", method = RequestMethod.POST)
public CtResponse getDeviceErrorList(HttpServletRequest request, HttpServletResponse response, @RequestBody Map<String, String> body) {
logger.info("req: " + request.getRequestURI());
CtResponse result = new CtResponse();
Boolean is_excel = _AG.toBoolean(body.get("is_excel"));
Long offset = _AG.toLong(body.get("offset"));
Long limit = _AG.toLong(body.get("limit"));
if (limit > 100)
limit = 100L;
Boolean is_group_access = _AG.toBoolean(body.get("is_group_access"));
String date_start = body.get("date_start");
String date_end = body.get("date_end");
Long biz_group_id = _AG.toLong(body.get("biz_group_id"));
String device_name = body.get("device_name");
String uid1 = body.get("uid1");
Long uid1_type = _AG.toLong(body.get("uid1_type"));
String type = body.get("type");
String error_code = body.get("error_code");
Long error_type = _AG.toLong(body.get("error_type"));
Map<String, Object> mapResult = deviceErrorService.getDeviceErrorList(is_excel, offset, limit, is_group_access, biz_group_id, date_start, date_end,
device_name, uid1, uid1_type, type, error_code, error_type);
if (is_excel) {
List<Map<String, String>> list = (List<Map<String, String>>)mapResult.get("list");
for (Map<String, String> item : list) {
item.replace("reg_time", _AG.timestampToStr(item.get("reg_time")));
}
// The vending machine type and the raw telegram are kept in the DB only, never shown
String[] arrHeader = {"발생시간", "무인기기명", "단말기 TID", "에러유형", "에러내용"};
String[] arrColumn = {"reg_time", "device_name", "uid1", "error_type", "error_desc"};
CtExcelMaker.makeExcelResponse(request, response, "ERROR품절내역목록", arrHeader, arrColumn, list, null);
return null;
}
result.put("result", mapResult);
return result;
}
@RequestMapping(value = "/get-device-error-detail.do", method = RequestMethod.POST)
public CtResponse getDeviceErrorDetail(HttpServletRequest request, HttpServletResponse response, @RequestBody Map<String, String> body) {
logger.info("req: " + request.getRequestURI());
CtResponse result = new CtResponse();
Long device_error_id = _AG.toLong(body.get("device_error_id"));
if (device_error_id == 0) {
result.setErrCode(ErrorCode.INVALID_PARAMETER);
return result;
}
result.put("result", deviceErrorService.getDeviceErrorDetail(device_error_id));
return result;
}
@RequestMapping(value = "/add-device-error.do", method = RequestMethod.POST)
public CtResponse addDeviceError(HttpServletRequest request, HttpServletResponse response, @RequestBody Map<String, String> body) {
logger.info("req: " + request.getRequestURI());
CtResponse result = new CtResponse();
String uid1 = body.get("uid1");
Long uid1_type = _AG.toLong(body.get("uid1_type"));
String type = body.get("type");
String error_code = body.get("error_code");
Long error_type = _AG.toLong(body.get("error_type"));
if (_AG.hasText(uid1) == false || uid1_type == null || _AG.hasText(type) == false) {
result.setErrCode(ErrorCode.INVALID_PARAMETER);
return result;
}
result = deviceErrorService.addDeviceError(uid1, uid1_type, type, error_code, error_type);
return result;
}
}
@@ -0,0 +1,44 @@
package com.handong.smartservice.controller.api;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.bind.annotation.*;
import com.handong.smartservice.component.CtResponse;
import com.handong.smartservice.component.SMSSender;
import jakarta.servlet.http.HttpServletRequest;
/**
* TEMPORARY: sends one fixed test message so the Munja114 integration can be verified
* from the sign in screen. The callback, the receiver and the body are fixed here on
* purpose - the endpoint is reachable without a token, so it must not be able to send
* an arbitrary message to an arbitrary number. Delete this class once the test is done.
*/
@RestController
@RequestMapping("/api")
class SmsTestController {
private final Logger logger = LoggerFactory.getLogger(this.getClass());
private static final String TEST_CALLBACK = "0314263883";
private static final String TEST_PHONE = "01040334076";
private static final String TEST_MESSAGE = "안녕하세요. 테스트입니다.";
@RequestMapping(value = "/send-sms-test.do", method = RequestMethod.POST)
public CtResponse sendSmsTest(HttpServletRequest request) {
logger.info("req: " + request.getRequestURI());
SMSSender.Result sendResult = SMSSender.send(TEST_CALLBACK, TEST_PHONE, TEST_MESSAGE);
logger.info("send-sms-test: " + sendResult);
CtResponse result = new CtResponse();
result.put("code", sendResult.code);
result.put("msg", sendResult.msg);
result.put("sentCount", sendResult.sentCount);
result.put("remainCount", sendResult.remainCount);
result.put("callback", TEST_CALLBACK);
result.put("phone", TEST_PHONE);
return result;
}
}
@@ -74,8 +74,8 @@ class TransactionController {
item.replace("order_time", _AG.timestampToStr(item.get("order_time")));
}
String[] arrHeader = {"거래시간", "단말기 TID", "금액", "결제유형", "승인번호", "컬럼번호", "상품코드", "상품명", "결제서비스업체"};
String[] arrColumn = {"order_time", "uid1", "amount", "type", "approval", "column_no", "code", "goods_name", "pay_vendor"};
String[] arrHeader = {"거래시간", "단말기 TID", "금액", "결제유형", "승인번호", "카드번호", "컬럼번호", "상품코드", "상품명", "결제서비스업체"};
String[] arrColumn = {"order_time", "uid1", "amount", "type", "approval", "card_num", "column_no", "code", "goods_name", "pay_vendor"};
CtExcelMaker.makeExcelResponse(request, response, "거래내역목록", arrHeader, arrColumn, list, null);
return null;
@@ -13,7 +13,7 @@ public interface AccountMapper {
String date_start, String date_end, Long gid, String user_id, String email, String name, String phone, Long biz_group_id);
List<Map<String, Object>> selectAccount2(Boolean isCount, Long offset, Long limit,
String date_start, String date_end, Boolean is_excel, Long gid, String user_id, String email, String name, String phone, Long biz_group_id);
String date_start, String date_end, Boolean is_excel, Long gid, String user_id, String email, String name, String phone, Long biz_group_id, Long state);
int insertAccount(Map<String, Object> params);
@@ -0,0 +1,25 @@
package com.handong.smartservice.mapper;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
import java.util.Map;
/**
* Read only queries for the dashboard, all limited to a short period.
* is_all == true means the caller is a super admin and sees every group,
* otherwise the rows are limited to biz_group_id and its sub groups.
*/
@Mapper
public interface DashboardMapper {
List<Map<String, Object>> selectNoticeOfPeriod(String date_start, String date_end);
List<Map<String, Object>> selectSalesOfPeriod(Boolean is_all, Long biz_group_id, String date_start, String date_end);
List<Map<String, Object>> selectDeviceErrorOfPeriod(Boolean is_all, Long biz_group_id, String date_start, String date_end);
List<Map<String, Object>> selectAccountOfPeriod(Boolean is_all, Long biz_group_id, String date_start, String date_end);
List<Map<String, Object>> selectGoodsOfPeriod(Boolean is_all, Long biz_group_id, String date_start, String date_end);
}
@@ -0,0 +1,18 @@
package com.handong.smartservice.mapper;
import org.apache.ibatis.annotations.Mapper;
import java.util.List;
import java.util.Map;
@Mapper
public interface DeviceErrorMapper {
List<Map<String, Object>> selectDeviceError(Boolean isCount, Boolean is_excel, Long offset, Long limit, Boolean is_group_access,
Long biz_group_id, String date_start, String date_end, String device_name, String uid1, Long uid1_type, String type,
String error_code, Long error_type);
Map<String, Object> selectDeviceErrorById(Long device_error_id);
int insertDeviceError(Map<String, Object> params);
}
@@ -54,17 +54,17 @@ public class AccountService {
return result;
}
public Map<String, Object> getAccountList(Long offset, Long limit, String date_start, String date_end, Boolean is_excel, Boolean is_group_access, String user_id, String email, String name, String phone, Long biz_group_id) {
public Map<String, Object> getAccountList(Long offset, Long limit, String date_start, String date_end, Boolean is_excel, Boolean is_group_access, String user_id, String email, String name, String phone, Long biz_group_id, Long state) {
Map<String, Object> mapResult = new HashMap<>();
//if (is_group_access) {
List<Map<String, Object>> listMap = (List<Map<String, Object>>)accountMapper.selectAccount2(false, offset, limit,
date_start, date_end, is_excel, 0L, user_id, email, name, phone, biz_group_id);
date_start, date_end, is_excel, 0L, user_id, email, name, phone, biz_group_id, state);
mapResult.put("list", listMap);
Long totalCount = _AG.getNumberFromQuery("COUNT(*)", accountMapper.selectAccount2(true, offset, limit,
date_start, date_end, is_excel, 0L, user_id, email, name, phone, biz_group_id));
date_start, date_end, is_excel, 0L, user_id, email, name, phone, biz_group_id, state));
mapResult.put("totalCount", totalCount);
mapResult.put("offset", offset);
return mapResult;
@@ -150,7 +150,7 @@ public class AccountService {
if (changed == 0) {
result.setErrCode(ErrorCode.QUERY_ERROR);
}
DbLogger.insert(2L, "계정 삭제, id: " + ids, 2L);
DbLogger.insert(2L, "계정 해지, id: " + ids, 2L);
return result;
}
@@ -0,0 +1,50 @@
package com.handong.smartservice.service;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.Map;
import org.springframework.stereotype.Service;
import com.handong.smartservice.component.Permission;
import com.handong.smartservice.component.UserInfo;
import com.handong.smartservice.mapper.DashboardMapper;
@Service
public class DashboardService {
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd");
private final DashboardMapper dashboardMapper;
public DashboardService(DashboardMapper dashboardMapper) {
this.dashboardMapper = dashboardMapper;
}
/**
* Every widget of the dashboard in one call, limited to the last {@code days} days.
* The scope comes from the signed in account, never from the request.
*/
public Map<String, Object> getDashboard(Long days) {
UserInfo userInfo = UserInfo.getCurr();
Boolean is_all = Permission.PERM_ALL.equals(userInfo.getPermission());
Long biz_group_id = userInfo.getBizGroupId();
LocalDate today = LocalDate.now();
String date_end = today.format(DATE_FORMAT);
String date_start = today.minusDays(days - 1).format(DATE_FORMAT);
Map<String, Object> mapResult = new HashMap<>();
mapResult.put("date_start", date_start);
mapResult.put("date_end", date_end);
mapResult.put("notice_list", dashboardMapper.selectNoticeOfPeriod(date_start, date_end));
mapResult.put("sales_list", dashboardMapper.selectSalesOfPeriod(is_all, biz_group_id, date_start, date_end));
mapResult.put("device_error_list", dashboardMapper.selectDeviceErrorOfPeriod(is_all, biz_group_id, date_start, date_end));
mapResult.put("account_list", dashboardMapper.selectAccountOfPeriod(is_all, biz_group_id, date_start, date_end));
mapResult.put("goods_list", dashboardMapper.selectGoodsOfPeriod(is_all, biz_group_id, date_start, date_end));
return mapResult;
}
}
@@ -0,0 +1,156 @@
package com.handong.smartservice.service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.springframework.stereotype.Service;
import com.handong.smartservice._AG;
import com.handong.smartservice.component.CtResponse;
import com.handong.smartservice.component.DeviceErrorCode;
import com.handong.smartservice.mapper.BizGroupMapper;
import com.handong.smartservice.mapper.DeviceErrorMapper;
import com.handong.smartservice.mapper.GoodsMapper;
import com.handong.smartservice.mapper.TerminalMapper;
@Service
public class DeviceErrorService {
private final DeviceErrorMapper deviceErrorMapper;
private final GoodsMapper goodsMapper;
private final BizGroupMapper bizGroupMapper;
private final TerminalMapper terminalMapper;
public DeviceErrorService(DeviceErrorMapper deviceErrorMapper, GoodsMapper goodsMapper, BizGroupMapper bizGroupMapper, TerminalMapper terminalMapper) {
this.deviceErrorMapper = deviceErrorMapper;
this.goodsMapper = goodsMapper;
this.bizGroupMapper = bizGroupMapper;
this.terminalMapper = terminalMapper;
}
public Map<String, Object> getDeviceErrorList(Boolean is_excel, Long offset, Long limit, Boolean is_group_access, Long biz_group_id,
String date_start, String date_end, String device_name, String uid1, Long uid1_type, String type,
String error_code, Long error_type) {
Map<String, Object> mapResult = new HashMap<>();
List<Map<String, Object>> listMap = (List<Map<String, Object>>)deviceErrorMapper.selectDeviceError(false, is_excel, offset, limit, is_group_access,
biz_group_id, date_start, date_end, device_name, uid1, uid1_type, type, error_code, error_type);
// The telegram is kept in the DB only, so the readable summary is built here
for (Map<String, Object> item : listMap)
item.put("error_desc", DeviceErrorCode.describe((String)item.get("error_code")));
mapResult.put("list", listMap);
Long totalCount = _AG.getNumberFromQuery("COUNT(*)", deviceErrorMapper.selectDeviceError(true, is_excel, offset, limit, is_group_access,
biz_group_id, date_start, date_end, device_name, uid1, uid1_type, type, error_code, error_type));
mapResult.put("totalCount", totalCount);
mapResult.put("offset", offset);
return mapResult;
}
/**
* Parses the telegram of one record into a line per fault / sold out column,
* and names the goods that are registered on each sold out column.
*/
public Map<String, Object> getDeviceErrorDetail(Long device_error_id) {
Map<String, Object> mapResult = new HashMap<>();
List<Map<String, Object>> list = new ArrayList<>();
Map<String, Object> mapError = deviceErrorMapper.selectDeviceErrorById(device_error_id);
if (mapError == null) {
mapResult.put("list", list);
return mapResult;
}
String error_code = (String)mapError.get("error_code");
for (String fault : DeviceErrorCode.getFaults(error_code)) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("detail", fault);
item.put("goods_code", null);
item.put("goods_name", null);
item.put("state", DeviceErrorCode.STATE_FAULT);
list.add(item);
}
List<Integer> columns = DeviceErrorCode.getSoldOutColumns(error_code);
if (columns.size() > 0) {
Map<Long, Map<String, Object>> mapGoods = getGoodsByColumn(toLong(mapError.get("device_id")));
for (Integer column : columns) {
Map<String, Object> goods = mapGoods.get(column.longValue());
Map<String, Object> item = new LinkedHashMap<>();
item.put("detail", column + "번 컬럼 품절");
item.put("goods_code", goods != null ? goods.get("code") : null);
item.put("goods_name", goods != null ? goods.get("name") : null);
item.put("state", DeviceErrorCode.STATE_SOLD_OUT);
list.add(item);
}
}
mapResult.put("result_info", mapError);
mapResult.put("list", list);
return mapResult;
}
// device_goods keeps the column number as text ("01", "02", ...), so it is normalized to a number
private Map<Long, Map<String, Object>> getGoodsByColumn(Long device_id) {
Map<Long, Map<String, Object>> mapGoods = new HashMap<>();
if (device_id == 0)
return mapGoods;
List<Map<String, Object>> listGoods = goodsMapper.selectDeviceGoods(device_id, null, null);
if (listGoods == null)
return mapGoods;
for (Map<String, Object> goods : listGoods)
mapGoods.put(_AG.toLong((String)goods.get("column_no")), goods);
return mapGoods;
}
private Long toLong(Object obj) {
return obj instanceof Number ? ((Number)obj).longValue() : 0L;
}
public CtResponse addDeviceError(String uid1, Long uid1_type, String type, String error_code, Long error_type) {
CtResponse result = new CtResponse();
Long biz_group_id = 0L;
Long device_id = 0L;
// The kiosk only knows its own TID, so the group and the device are resolved from it
Map<String, Object> terminal = terminalMapper.selectTerminal3(uid1, uid1_type);
if (terminal != null) {
device_id = (Long)terminal.get("device_id");
Map<String, Object> mapBizGroup = bizGroupMapper.selectBizGroupByDeviceId(device_id);
if (mapBizGroup != null) {
biz_group_id = (Long)mapBizGroup.get("biz_group_id");
}
else {
Long terminal_id = (Long)terminal.get("terminal_id");
Map<String, Object> mapBizGroup2 = bizGroupMapper.selectBizGroupByTerminalId(terminal_id);
if (mapBizGroup2 != null) {
biz_group_id = (Long)mapBizGroup2.get("biz_group_id");
}
}
}
Map<String, Object> params = new HashMap<>();
params.put("uid1", uid1);
params.put("uid1_type", uid1_type);
params.put("type", type);
params.put("error_code", error_code);
params.put("error_type", error_type);
params.put("biz_group_id", biz_group_id);
params.put("device_id", device_id);
deviceErrorMapper.insertDeviceError(params);
return result;
}
}
@@ -73,7 +73,10 @@
</choose>
FROM account AS AC
JOIN group_tree GT ON GT.biz_group_id = AC.biz_group_id
WHERE state != 2
WHERE 1 = 1
<if test='state != null and state != 0'>
AND AC.state = #{state}
</if>
<if test='date_start != null and date_start != ""'>
AND #{date_start} &lt;= DATE(AC.reg_time)
</if>
@@ -96,7 +99,8 @@
AND AC.phone like CONCAT(#{phone}, '%')
</if>
<if test='isCount == false and is_excel == false'>
ORDER BY gid DESC LIMIT #{limit} OFFSET #{offset}
/* the terminated accounts always go last */
ORDER BY (AC.state = 2), AC.gid DESC LIMIT #{limit} OFFSET #{offset}
</if>
</select>
@@ -0,0 +1,98 @@
<?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.handong.smartservice.mapper.DashboardMapper">
<sql id="targetGroupCte">
<if test='is_all == false'>
WITH target_group AS (
WITH RECURSIVE group_tree AS (
SELECT
BG1.biz_group_id,
1 AS depth
FROM biz_group BG1
WHERE BG1.state != 2 AND BG1.biz_group_id = #{biz_group_id}
UNION ALL
SELECT
BG2.biz_group_id,
GT.depth + 1
FROM biz_group BG2
INNER JOIN group_tree GT ON BG2.pid = GT.biz_group_id
WHERE BG2.state != 2 AND GT.depth &lt; 10
)
SELECT biz_group_id
FROM group_tree
)
</if>
</sql>
<select id="selectNoticeOfPeriod" resultType="map">
SELECT N.notice_id, N.reg_time, N.title
FROM notice AS N
WHERE N.state != 2
AND #{date_start} &lt;= DATE(N.reg_time) AND DATE(N.reg_time) &lt;= #{date_end}
ORDER BY N.reg_time DESC
</select>
<select id="selectSalesOfPeriod" resultType="map">
<include refid="targetGroupCte" />
SELECT
DATE_FORMAT(T.reg_time, '%Y-%m-%d') AS date,
COUNT(*) AS total_count,
SUM(CASE WHEN T.type IN ('D1','I1','B1','TM1','EB1') THEN 1 ELSE 0 END) AS approval_count,
SUM(CASE WHEN T.type IN ('D4','I4','B4','TM4','EB4') THEN 1 ELSE 0 END) AS cancel_count,
SUM(T.amount) AS total_amount
FROM transactions AS T
WHERE #{date_start} &lt;= DATE(T.reg_time) AND DATE(T.reg_time) &lt;= #{date_end}
<if test='is_all == false'>
AND T.biz_group_id IN ( SELECT biz_group_id FROM target_group )
</if>
GROUP BY DATE_FORMAT(T.reg_time, '%Y-%m-%d')
ORDER BY date DESC
</select>
<select id="selectDeviceErrorOfPeriod" resultType="map">
<include refid="targetGroupCte" />
SELECT
DATE_FORMAT(E.reg_time, '%Y-%m-%d') AS date,
SUM(CASE WHEN E.error_type = 1 THEN 1 ELSE 0 END) AS fault_count,
SUM(CASE WHEN E.error_type = 2 THEN 1 ELSE 0 END) AS sold_out_count,
COUNT(*) AS total_count
FROM device_error AS E
WHERE #{date_start} &lt;= DATE(E.reg_time) AND DATE(E.reg_time) &lt;= #{date_end}
<if test='is_all == false'>
AND E.biz_group_id IN ( SELECT biz_group_id FROM target_group )
</if>
GROUP BY DATE_FORMAT(E.reg_time, '%Y-%m-%d')
ORDER BY date DESC
</select>
<select id="selectAccountOfPeriod" resultType="map">
<include refid="targetGroupCte" />
SELECT A.gid, A.reg_time, A.user_id, A.name, A.nick_name, BG.name AS biz_group_name
FROM account AS A
LEFT JOIN biz_group BG ON BG.biz_group_id = A.biz_group_id
WHERE A.state != 2
AND #{date_start} &lt;= DATE(A.reg_time) AND DATE(A.reg_time) &lt;= #{date_end}
<if test='is_all == false'>
AND A.biz_group_id IN ( SELECT biz_group_id FROM target_group )
</if>
ORDER BY A.reg_time DESC
</select>
<select id="selectGoodsOfPeriod" resultType="map">
<include refid="targetGroupCte" />
SELECT G.goods_id, G.reg_time, G.code, G.name, G.price, BG.name AS biz_group_name
FROM goods AS G
LEFT JOIN biz_group BG ON BG.biz_group_id = G.biz_group_id
WHERE G.state != 2
AND #{date_start} &lt;= DATE(G.reg_time) AND DATE(G.reg_time) &lt;= #{date_end}
<if test='is_all == false'>
AND G.biz_group_id IN ( SELECT biz_group_id FROM target_group )
</if>
ORDER BY G.reg_time DESC
</select>
</mapper>
@@ -0,0 +1,77 @@
<?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.handong.smartservice.mapper.DeviceErrorMapper">
<select id="selectDeviceError" resultType="map">
<if test='is_group_access == false'>
WITH target_group AS (
WITH RECURSIVE group_tree AS (
SELECT
BG1.biz_group_id,
1 AS depth
FROM biz_group BG1
WHERE BG1.state != 2 AND BG1.biz_group_id = #{biz_group_id}
UNION ALL
SELECT
BG2.biz_group_id,
GT.depth + 1
FROM biz_group BG2
INNER JOIN group_tree GT ON BG2.pid = GT.biz_group_id
WHERE BG2.state != 2 AND GT.depth &lt; 10
)
SELECT biz_group_id
FROM group_tree
)
</if>
SELECT
<choose>
<when test='isCount == true'>
COUNT(*)
</when>
<otherwise>
E.device_error_id, E.reg_time, E.device_id, D.name AS device_name, E.type, E.uid1, E.uid1_type,
E.error_code, E.error_type
</otherwise>
</choose>
FROM device_error AS E
LEFT JOIN device D ON E.device_id = D.device_id
WHERE
<choose>
<when test='is_group_access == false'>
E.biz_group_id IN ( SELECT biz_group_id FROM target_group )
</when>
<otherwise>
E.biz_group_id = #{biz_group_id}
</otherwise>
</choose>
<if test='date_start != null and date_start != ""'>AND #{date_start} &lt;= DATE(E.reg_time)</if>
<if test='date_end != null and date_end != ""'>AND DATE(E.reg_time) &lt;= #{date_end}</if>
<if test='device_name != null and device_name != ""'>AND D.name like CONCAT('%', #{device_name}, '%')</if>
<if test='uid1 != null and uid1 != ""'>AND E.uid1 = #{uid1}</if>
<if test='uid1_type != null and uid1_type != 0'>AND E.uid1_type = #{uid1_type}</if>
<if test='type != null and type != ""'>AND E.type = #{type}</if>
<if test='error_code != null and error_code != ""'>AND E.error_code like CONCAT('%', #{error_code}, '%')</if>
<if test='error_type != null and error_type != 0'>AND E.error_type = #{error_type}</if>
<if test='isCount == false and is_excel == false'>
ORDER BY E.device_error_id DESC LIMIT #{limit} OFFSET #{offset}
</if>
</select>
<select id="selectDeviceErrorById" resultType="map">
SELECT
E.device_error_id, E.reg_time, E.biz_group_id, E.device_id, D.name AS device_name,
E.type, E.uid1, E.uid1_type, E.error_code, E.error_type
FROM device_error AS E
LEFT JOIN device D ON E.device_id = D.device_id
WHERE E.device_error_id = #{device_error_id}
</select>
<insert id="insertDeviceError" parameterType="map">
INSERT INTO device_error (type, biz_group_id, device_id, uid1, uid1_type, error_code, error_type)
VALUES (#{type}, #{biz_group_id}, #{device_id}, #{uid1}, #{uid1_type}, #{error_code}, #{error_type})
</insert>
</mapper>
@@ -63,11 +63,17 @@
COUNT(*)
</when>
<otherwise>
T.reg_time, T.order_time, D.name AS device_name, T.amount, T.type, T.approval, T.uid1, T.pay_name, T.pay_vendor, T.column_no, T.code, T.goods_name, T.pay_unique_num, T.order_time
T.reg_time, T.order_time, D.name AS device_name, T.amount, T.type, T.approval, T.uid1, T.pay_name, T.pay_vendor, T.column_no, T.code, T.goods_name, T.pay_unique_num, T.order_time,
T.card_num, T.rtid,
/* the original approval, shown under a credit cancel row */
ORG.reg_time AS org_reg_time, ORG.order_time AS org_order_time, ORG.type AS org_type, ORG.amount AS org_amount,
ORG.approval AS org_approval, ORG.card_num AS org_card_num, ORG.pay_name AS org_pay_name, ORG.pay_vendor AS org_pay_vendor,
ORG.column_no AS org_column_no, ORG.code AS org_code, ORG.goods_name AS org_goods_name
</otherwise>
</choose>
FROM transactions AS T
JOIN device D ON T.device_id = D.device_id
LEFT JOIN transactions ORG ON ORG.transaction_id = T.rtid
WHERE
<choose>
<when test='is_group_access == false'>
@@ -0,0 +1,180 @@
"use client"
import ComponentCard from '@/components/common/ComponentCard2';
import PageBreadcrumb from "@/components/common/PageBreadCrumb2";
import Badge from "@/components/ui/badge/Badge";
import { BoxCubeIcon, DocsIcon, GroupIcon, PageIcon, PieChartIcon } from "@/icons";
import React, { useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import api, { convertDateTime3, useAuthStore } from '@/lib/_AG';
const DASHBOARD_DAYS = 7;
type Column = {
title: string;
key: string;
align?: "left" | "center" | "right";
renderItem?: (item: any) => React.ReactNode;
};
/** Small fixed table: the dashboard only shows one week, so there is no paging. */
const DashTable = ({ columns, rows, emptyText }: { columns: Column[]; rows: any[]; emptyText: string }) => (
<div className="overflow-x-auto">
<table className="w-full min-w-[420px]">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-800">
{ columns.map((col, index) => (
<th key={index} className={"px-3 py-2.5 text-theme-sm font-medium text-gray-600 dark:text-gray-400 text-" + (col.align ?? "center")}>
{col.title}
</th>
))}
</tr>
</thead>
<tbody>
{ rows.length === 0 &&
<tr>
<td colSpan={columns.length} className="px-3 py-10 text-center text-theme-sm text-gray-500 dark:text-gray-400">
{emptyText}
</td>
</tr>
}
{ rows.map((row, rowIndex) => (
<tr key={rowIndex} className="border-b border-gray-100 last:border-0 dark:border-gray-800">
{ columns.map((col, colIndex) => (
<td key={colIndex} className={"px-3 py-2.5 text-theme-sm text-gray-800 dark:text-white/90 text-" + (col.align ?? "center")}>
{ col.renderItem ? col.renderItem(row) : (row[col.key] ?? "-") }
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
);
const toNumber = (value: any) => Number(value ?? 0);
const toComma = (value: any) => toNumber(value).toLocaleString("ko-KR");
const toDate = (value: any) => (value ? String(value).substring(0, 10) : "-");
export default function Dashboard() {
const router = useRouter();
const tokenPayload = useAuthStore((s) => s.tokenPayload); //Bruce, JWT
const [ dateStart, setDateStart ] = useState<string>("");
const [ dateEnd, setDateEnd ] = useState<string>("");
const [ noticeList, setNoticeList ] = useState<any[]>([]);
const [ salesList, setSalesList ] = useState<any[]>([]);
const [ deviceErrorList, setDeviceErrorList ] = useState<any[]>([]);
const [ accountList, setAccountList ] = useState<any[]>([]);
const [ goodsList, setGoodsList ] = useState<any[]>([]);
function reqDashboard() {
api.post('/api/get-dashboard.do', { days: DASHBOARD_DAYS }).then((resp) => {
if (resp.data.errCode != 0) {
alert("일시적으로 사용할 수 없습니다 : " + resp.data.errCode);
return;
}
const result = resp.data.result;
setDateStart(result.date_start ?? "");
setDateEnd(result.date_end ?? "");
setNoticeList(result.notice_list ?? []);
setSalesList(result.sales_list ?? []);
setDeviceErrorList(result.device_error_list ?? []);
setAccountList(result.account_list ?? []);
setGoodsList(result.goods_list ?? []);
})
.catch((err) => console.error(err));
}
useEffect(() => {
if (tokenPayload == undefined)
return;
reqDashboard();
}, [tokenPayload]);
const salesColumns: Column[] = [
{ title: "날짜", key: "date", renderItem: (item) => item.date },
{ title: "승인", key: "approval_count", align: "right", renderItem: (item) => toComma(item.approval_count) + "건" },
{ title: "취소", key: "cancel_count", align: "right", renderItem: (item) => toComma(item.cancel_count) + "건" },
{ title: "전체", key: "total_count", align: "right", renderItem: (item) => toComma(item.total_count) + "건" },
{ title: "금액", key: "total_amount", align: "right", renderItem: (item) => toComma(item.total_amount) + "원" },
];
const deviceErrorColumns: Column[] = [
{ title: "날짜", key: "date", renderItem: (item) => item.date },
{
title: "단말기 이상", key: "fault_count", align: "right",
renderItem: (item) => toNumber(item.fault_count) > 0
? <Badge size="sm" color="error">{toComma(item.fault_count)}</Badge>
: "0건",
},
{
title: "품절", key: "sold_out_count", align: "right",
renderItem: (item) => toNumber(item.sold_out_count) > 0
? <Badge size="sm" color="warning">{toComma(item.sold_out_count)}</Badge>
: "0건",
},
{ title: "합계", key: "total_count", align: "right", renderItem: (item) => toComma(item.total_count) + "건" },
];
const noticeColumns: Column[] = [
{ title: "등록일", key: "reg_time", renderItem: (item) => convertDateTime3(item.reg_time) },
{ title: "제목", key: "title", align: "left" },
];
const accountColumns: Column[] = [
{ title: "등록일", key: "reg_time", renderItem: (item) => toDate(item.reg_time) },
{ title: "아이디", key: "user_id" },
{ title: "이름", key: "nick_name", renderItem: (item) => item.nick_name ?? item.name ?? "-" },
{ title: "그룹(사업자)", key: "biz_group_name", align: "left" },
];
const goodsColumns: Column[] = [
{ title: "등록일", key: "reg_time", renderItem: (item) => toDate(item.reg_time) },
{ title: "상품코드", key: "code" },
{ title: "상품명", key: "name", align: "left" },
{ title: "가격", key: "price", align: "right", renderItem: (item) => toComma(item.price) + "원" },
];
const period = dateStart && dateEnd ? dateStart + " ~ " + dateEnd : "";
return (
<div>
<PageBreadcrumb pageTitle1="홈 (대시보드)" />
<div className="space-y-3">
<div className="rounded-lg border border-gray-200 bg-gray-50 px-4 py-3 text-sm text-gray-600 dark:border-gray-800 dark:bg-white/[0.03] dark:text-gray-400">
{DASHBOARD_DAYS} . { period && <span className="font-medium text-gray-800 dark:text-white/90">({period})</span> }
</div>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
<ComponentCard title="매출집계 (일별)" titleIcon={<PieChartIcon />}>
<DashTable columns={salesColumns} rows={salesList} emptyText="기간 내 거래내역이 없습니다." />
</ComponentCard>
<ComponentCard title="무인기기 ERROR/품절내역 (일별)" titleIcon={<BoxCubeIcon />}>
<DashTable columns={deviceErrorColumns} rows={deviceErrorList} emptyText="기간 내 이상/품절 내역이 없습니다." />
</ComponentCard>
</div>
<div className="grid grid-cols-1 gap-3 xl:grid-cols-2">
<ComponentCard title="추가된 계정" titleIcon={<GroupIcon />}>
<DashTable columns={accountColumns} rows={accountList} emptyText="기간 내 추가된 계정이 없습니다." />
</ComponentCard>
<ComponentCard title="추가된 상품" titleIcon={<DocsIcon />}>
<DashTable columns={goodsColumns} rows={goodsList} emptyText="기간 내 추가된 상품이 없습니다." />
</ComponentCard>
</div>
<ComponentCard title="공지사항" titleIcon={<PageIcon />}>
<DashTable columns={noticeColumns} rows={noticeList} emptyText="기간 내 등록된 공지사항이 없습니다." />
</ComponentCard>
</div>
</div>
);
}
@@ -23,6 +23,26 @@ import Select from "@/components/form/Select2";
import PageCountSelector from "@/components/PageCountSelector";
const ACCOUNT_STATE_LABEL: { [key: string]: string } = {
"1": "정상", "2": "탈퇴", "3": "휴면", "4": "정지", "5": "잠김",
};
const ACCOUNT_STATE_OPTIONS = [
{ label: "정상", value: "1" },
{ label: "탈퇴", value: "2" },
{ label: "휴면", value: "3" },
{ label: "정지", value: "4" },
{ label: "잠김", value: "5" },
];
const accountStateColor = (state: any) => {
const value = Number(state);
if (value === 1) return "success";
if (value === 2) return "light";
if (value === 4 || value === 5) return "error";
return "warning";
};
const SEARCH_LABEL_WIDTH_PX = 80;
//const COUNT_PER_PAGE = 10;
const PAGE_NUM_COUNT = 5;
@@ -49,6 +69,7 @@ export default function Account() {
const [ user_id, setUserId ] = useState<string>("");
const [ email, setEmail ] = useState<string>("");
const [ nick_name, setNickName ] = useState<string>("");
const [ state, setState ] = useState<string>("");
const [ date_start, setDateStart ] = useState<string>();
const [ date_end, setDateEnd ] = useState<string>();
@@ -70,6 +91,7 @@ export default function Account() {
user_id: user_id,
email: email,
nick_name: nick_name,
state: state,
date_start: date_start,
date_end: date_end,
biz_group_id: biz_group_id,
@@ -200,8 +222,8 @@ export default function Account() {
openModal();
};
const handleRowClickDelete = (row: number) => {
if (confirm("'" + tableData[row].user_id + "' 계정을 삭제하시겠습니까?")) {
const handleRowClickTerminate = (row: number) => {
if (confirm("'" + tableData[row].user_id + "' 계정을 해지하시겠습니까?")) {
reqRemoveAccount(tableData[row].gid);
}
};
@@ -251,8 +273,8 @@ export default function Account() {
title: "상태",
key: "state",
renderItem: (item : any) => (
<Badge size="sm" color={item.state === 1 ? "success" : item.state === 4 ? "error" : "warning"}>
{item.state === 1 ? "정상" : "정지"}
<Badge size="sm" color={accountStateColor(item.state)}>
{ACCOUNT_STATE_LABEL[String(item.state)] ?? ""}
</Badge>
)
},
@@ -268,13 +290,13 @@ export default function Account() {
),
},
{
title: "삭제",
title: "해지",
key: "_",
renderItem: (item: any, row: number) => (
<div>
<TrashBinIcon
className="cursor-pointer hover:fill-error-500 dark:hover:fill-error-500 fill-gray-700 dark:fill-gray-400"
onClick={() => {handleRowClickDelete(row)}} />
onClick={() => {handleRowClickTerminate(row)}} />
</div>
),
},
@@ -323,7 +345,7 @@ export default function Account() {
<ComponentCard title="계정 목록 조회" titleIcon={<TableIcon />}>
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-4">
<WithLabel label="아이디" label_width={SEARCH_LABEL_WIDTH_PX}>
<Input type="text" placeholder="조회할 아이디를 입력하세요." value={user_id} onChange={(e) => setUserId(e.target.value)} />
</WithLabel>
@@ -333,6 +355,14 @@ export default function Account() {
<WithLabel label="닉네임" label_width={SEARCH_LABEL_WIDTH_PX}>
<Input type="text" placeholder="조회할 닉네임을 입력하세요." value={nick_name} onChange={(e) => setNickName(e.target.value)} />
</WithLabel>
<WithLabel label="상태" label_width={SEARCH_LABEL_WIDTH_PX}>
<Select
placeholder="전체"
options={ACCOUNT_STATE_OPTIONS}
defaultValue={state}
onChange={(e) => setState(e.target.value)}
className="dark:bg-dark-900" />
</WithLabel>
</div>
<div className="space-y-2 grid grid-cols-1 gap-3 sm:grid-cols-2">
<WithLabel label="등록일" label_width={50}>
@@ -154,7 +154,10 @@ export default function AddModifyAccountModal({ multiState, onOk, isOpen, closeM
placeholder="선택하세요"
options={[
{ label: "정상", value: "1" },
{ label: "탈퇴", value: "2" },
{ label: "휴면", value: "3" },
{ label: "정지", value: "4" },
{ label: "잠김", value: "5" },
]}
defaultValue={multiState.values.state} onChange={(e) => multiState.set("state", e.target.value)}
className="dark:bg-dark-900" />
@@ -0,0 +1,435 @@
"use client"
import PageBreadcrumb from "@/components/common/PageBreadCrumb2";
import ComponentCard from '@/components/common/ComponentCard2';
import BizGroupPanel from "@/components/BizGroupPanel";
import CtTable1 from '@/components/tables/CtTable1';
import Button from '@/components/ui/button/Button2';
import { DocsIcon, TableIcon, FilterIcon } from "@/icons";
import WithLabel from '@/components/form/WithLabel';
import Input from '@/components/form/input/InputField2';
import DatePicker from '@/components/form/date-picker2';
import React, { Key } from "react";
import { useEffect, useState } from 'react';
import api, { addDaysYMD, convertDateTime3, postFileDownload, todayYMD, useAuthStore, useUiLoadingStore } from '@/lib/_AG';
import CtTree, { CtTreeNode, findNodeByKey, findRootNodeByKey } from "@/components/CtTree";
import { reqBizGroupTree2, reqBizGroupTree3 } from "../biz-group/BizGroupContent";
import Checkbox from "@/components/form/input/Checkbox";
import CtSelectedTextField from "@/components/CtSelectedText";
import CtDrawer from "@/components/CtDrawer";
import { useModal } from "@/hooks/useModal";
import { useMultiState } from "@/hooks/useMultiState";
import ErrorDetailModal, { ErrorDetailModalProps, DeviceErrorDetailItem } from "./ErrorDetailModal";
import PageCountSelector from "@/components/PageCountSelector";
import InputSelectField from "@/components/form/input/InputSelectField";
import Select from "@/components/form/Select2";
import { deviceErrorTypeLabel } from "@/lib/deviceErrorCode";
const SEARCH_LABEL_WIDTH_PX = 80;
//const COUNT_PER_PAGE = 10;
const PAGE_NUM_COUNT = 5;
export default function DeviceError() {
const tokenPayload = useAuthStore((s) => s.tokenPayload); //Bruce, JWT
const isLoadingGlobal = useUiLoadingStore((s) => s.isLoading);
const [ tableData, setTableData ] = useState<any[]>([]);
const [ tableOffset, setTableOffset ] = useState<number>(0);
const [ tableTotalCount, setTableTotalCount ] = useState<number>(0);
const [ COUNT_PER_PAGE, setCOUNT_PER_PAGE ] = useState<number>(20);
const [ treeData, setTreeData ] = useState<CtTreeNode[]>([]);
const [ selectedTreeKeys, setSelectedTreeKeys ] = useState<Key[]>([]);
const [ expandedTreeKeys, setExpandedTreeKeys ] = useState<Key[]>([]);
const [ isGroupAccess, setGroupAccess ] = useState(tokenPayload?.permission == 0xffffffff); //TempCode
const [ isTreeOpen, setTreeOpen ] = useState(tokenPayload?.permission == 0xffffffff); //TempCode
// group search fields
const [ name, setName ] = useState<string>("");
const [ biz_reg_num, setBizRegNum ] = useState<string>("");
//search fields
const [ device_name, setDeviceName ] = useState<string>("");
const [ uid1, setUid1 ] = useState<string>("");
const [ list_uid1, setListUid1 ] = useState<{label: string; value: string}[]>([]);
const [ error_type, setErrorType ] = useState<string>("");
const [ date_start, setDateStart ] = useState<string>(addDaysYMD(-7));
const [ date_end, setDateEnd ] = useState<string>(todayYMD());
const { isOpen, openModal, closeModal } = useModal();
const [ modalItems, setModalItems ] = useState<DeviceErrorDetailItem[]>([]);
const modalData = useMultiState<ErrorDetailModalProps>({device_error_id: 0, reg_time: "", device_name: "", uid1: "", uid1_type: 0, type: "", error_type: 0, error_code: "", biz_group_name: ""});
const [ isMobileMode, setIsMobileMode ] = useState<boolean>(false);
const [ isSearchFieldOpen, setIsSearchFieldOpen ] = useState<boolean>(false);
const [ listSelectedText, setListSelectedText ] = useState<string[]>([]);
const changeListSelectedText = (index: number, text: string) => {
setListSelectedText(prev => {
const copy = [...prev];
copy[index] = text;
return copy;
});
}
//function part
//
function reqUid1List() {
api.post('/api/list-uid1.do').then((resp) => {
console.log(resp);
if (resp.data.errCode == 0) {
if (resp.data.result.list.length == 0) {
setListUid1([]);
return;
}
setListUid1(
resp.data.result.list.map((item: { uid1: string; name: string }) => ({
label: item.uid1 + (item.name !== "" ? " (" + item.name + ")" : ""),
value: item.uid1,
}))
);
}
})
.catch((err) => console.error(err))
}
function reqDeviceErrorDetail(device_error_id: number) {
api.post('/api/get-device-error-detail.do', { device_error_id: device_error_id }).then((resp) => {
if (resp.data.errCode != 0) {
alert("일시적으로 사용할 수 없습니다 : " + resp.data.errCode);
return;
}
setModalItems(resp.data.result.list ?? []);
})
.catch((err) => console.error(err));
}
function reqDeviceErrorList(is_excel: boolean, biz_group_id: string, groupAccess: boolean) {
if (!biz_group_id)
return;
const params = {
is_group_access: groupAccess,
is_excel: is_excel,
offset: tableOffset,
limit: COUNT_PER_PAGE,
device_name: device_name,
uid1: uid1,
error_type: error_type,
date_start: date_start,
date_end: date_end,
biz_group_id: biz_group_id,
}
if (is_excel == true) {
postFileDownload('/api/get-device-error.do', params);
return;
}
api.post('/api/get-device-error.do', params).then((resp) => {
console.log(resp);
if (resp.data.errCode === 0) {
if (resp.data.result.list.length == 0) {
alert("조회 결과가 없습니다.");
setTableTotalCount(0);
setTableData([]);
return;
}
setTableTotalCount(resp.data.result.totalCount);
setTableData(resp.data.result.list);
}
else {
alert("일시적으로 사용할 수 없습니다 : " + resp.data.errCode);
}
})
.catch((err) => console.error(err));
}
// event processing part
//
// tree part //////////
const handleClickGroupSearch = () => {
if (tokenPayload?.permission == 0xffffffff) {
if (name == "" && biz_reg_num == "") {
alert("그룹(사업자)명 또는 사업자등록번호를 입력하셔야 합니다.");
return;
}
if (name != "" && name.length < 2) {
alert("그룹(사업자)명은 두글자 이상 입력해야 합니다.");
return;
}
if (biz_reg_num != "" && biz_reg_num.length < 2) {
alert("사업자등록번호는 두글자 이상 입력해야 합니다.");
return;
}
reqBizGroupTree2(true, "", "", "", "", name, biz_reg_num, treeData, setTreeData);
}
else {
reqBizGroupTree3(String(tokenPayload?.biz_group_id), setTreeData, setSelectedTreeKeys, setExpandedTreeKeys, (resp: any) => {
reqUid1List();
reqDeviceErrorList(false, String(resp.data.result.tree[0].key), isGroupAccess);
});
}
};
const handleClickNode = (key: Key) => {
if (isGroupAccess == false) {
const root = findRootNodeByKey(treeData, key as string);
reqDeviceErrorList(false, root.key as string, isGroupAccess);
return;
}
setTableOffset(0);
reqDeviceErrorList(false, key as string, isGroupAccess);
}
const handleClickTreeExpanded = (key: Key) => {
const node = findNodeByKey(treeData, key);
if (node.children == undefined)
reqBizGroupTree2(false, key as string, "", "", "", "", "2", treeData, setTreeData);
}
const handleClickGroupAccess = (value: boolean) => {
setGroupAccess(value);
if (tokenPayload?.permission == 0xffffffff) {
if (selectedTreeKeys.length <= 0) {
return;
}
reqDeviceErrorList(false, selectedTreeKeys[0] as string, value);
return;
}
if (value == false || selectedTreeKeys.length <= 0) {
reqDeviceErrorList(false, String(tokenPayload?.biz_group_id), value);
}
else {
reqDeviceErrorList(false, selectedTreeKeys[0] as string, value);
}
}
// tree part //////////
const handleClickDownload = () => {
reqDeviceErrorList(true, selectedTreeKeys[0] as string, isGroupAccess);
};
const handleClickSearch = () => {
if (selectedTreeKeys.length <= 0) {
alert("조회하려면 사업자를 선택해야 합니다.");
return;
}
reqDeviceErrorList(false, selectedTreeKeys[0] as string, isGroupAccess);
};
const handlePageChange = (page: number) => {
setTableOffset((page - 1) * COUNT_PER_PAGE);
};
const handleRowClickErrorDetail = (row: number) => {
// The group name is only shown as context, so a missing tree node must not block the detail
const selectedKey = selectedTreeKeys[0];
const node = selectedKey != undefined ? findRootNodeByKey(treeData, String(selectedKey)) : null;
modalData.setAll({...tableData[row],
biz_group_name: node?.name ?? "",
});
// The telegram is parsed on the server, which also names the goods of every sold out column
setModalItems([]);
reqDeviceErrorDetail(tableData[row].device_error_id);
openModal();
};
const handleModalOk = () => {
closeModal();
}
//
const handleChangeDeviceName = (e: React.ChangeEvent<HTMLInputElement>) => {
setDeviceName(e.target.value);
changeListSelectedText(0, e.target.value);
}
const handleChangeUid1 = (e: React.ChangeEvent<HTMLInputElement>) => {
setUid1(e.target.value);
changeListSelectedText(1, e.target.value);
}
const handleChangeErrorType = (e: React.ChangeEvent<HTMLSelectElement>) => {
setErrorType(e.target.value);
changeListSelectedText(2, e.target.options[e.target.selectedIndex].text);
}
useEffect(() => {
if (tokenPayload == undefined)
return;
if (tokenPayload?.permission == 0xffffffff)
return;
reqBizGroupTree3(String(tokenPayload?.biz_group_id), setTreeData, setSelectedTreeKeys, setExpandedTreeKeys, (resp: any) => {
reqUid1List();
setGroupAccess(tokenPayload?.permission == 0xffffffff);
setTreeOpen(tokenPayload?.permission == 0xffffffff);
reqDeviceErrorList(false, String(resp.data.result.tree[0].key), tokenPayload?.permission == 0xffffffff);
});
}, [tokenPayload]);
useEffect(() => {
reqDeviceErrorList(false, selectedTreeKeys[0] as string, isGroupAccess);
}, [tableOffset]);
const columns = [
{
title: "발생시간",
key: "reg_time",
renderItem: (item: any, row: number) => convertDateTime3(item.reg_time)
},
{
title: "무인기기명",
key: "device_name",
},
{
title: "단말기 TID",
key: "uid1",
renderItem: (item: any, row: number) => item.uid1 ? (item.uid1 + " (" + (item.uid1_type == 1 ? "KICC" : "NICE") + ")") : "",
viewMobile: true,
},
{
title: "에러유형",
key: "error_type",
renderItem: (item: any, row: number) => deviceErrorTypeLabel(item.error_type),
viewMobile: true,
},
{
title: "에러내용",
key: "error_desc",
viewMobile: true,
},
{
title: "상세내역",
key: "_",
renderItem: (item: any, row: number) => (
<button className="inline-flex items-center px-1 py-0.5 justify-center gap-1 rounded font-medium text-theme-xs bg-error-50 text-error-600 dark:bg-error-500/15 dark:text-error-500"
onClick={() => {handleRowClickErrorDetail(row)}}>
</button>
),
},
];
return (
<div>
<PageBreadcrumb pageTitle1="무인기기 ERROR/품절내역" />
<div className="space-y-3">
<BizGroupPanel
title="사업자 그룹별로 관리"
isOpen={isTreeOpen}
setOpen={setTreeOpen}
name={name}
bizRegNum={biz_reg_num}
onChangeName={setName}
onChangeBizRegNum={setBizRegNum}
onClickSearch={handleClickGroupSearch}
isDisabled={isLoadingGlobal}
searchLabelWidth={SEARCH_LABEL_WIDTH_PX}
>
<CtTree
treeData={treeData}
selectedKeys={selectedTreeKeys}
setSelectedKeys={setSelectedTreeKeys}
onClickNode={handleClickNode}
expandedKeys={expandedTreeKeys}
setExpandedKeys={setExpandedTreeKeys}
onClickExpanded={handleClickTreeExpanded}
/>
</BizGroupPanel>
<ComponentCard title="ERROR/품절내역 목록" titleIcon={<TableIcon />}>
<CtDrawer title="상세 조회" isMobileMode={isMobileMode} setIsMobileMode={setIsMobileMode} isSearchFieldOpen={isSearchFieldOpen} setIsSearchFieldOpen={setIsSearchFieldOpen}>
<div className="space-y-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-4">
<WithLabel label="무인기기명" label_width={SEARCH_LABEL_WIDTH_PX}>
<Input type="text" placeholder="무인기기명을 입력하세요." value={device_name} onChange={handleChangeDeviceName} />
</WithLabel>
<WithLabel label="단말기 TID" label_width={SEARCH_LABEL_WIDTH_PX}>
<InputSelectField type="text" placeholder="TID를 입력하세요." value={uid1} options={list_uid1} onChange={handleChangeUid1} />
</WithLabel>
<WithLabel label="에러유형" label_width={SEARCH_LABEL_WIDTH_PX}>
<Select
placeholder="전체"
options={[
{ label: "단말기 이상", value: "1" },
{ label: "품절", value: "2" },
]}
defaultValue={error_type} onChange={handleChangeErrorType}
className="dark:bg-dark-900" />
</WithLabel>
</div>
</div>
</CtDrawer>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<WithLabel label="발생일" label_width={50}>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-2">
<DatePicker
id="date_start-picker"
placeholder="조회 시작일"
onChange={(dates, currentDateString) => { setDateStart(currentDateString); }}
defaultDate={date_start}
/>
<DatePicker
id="date_end-picker"
placeholder="조회 종료일"
onChange={(dates, currentDateString) => { setDateEnd(currentDateString); }}
defaultDate={date_end}
/>
</div>
</WithLabel>
<div className="flex items-center justify-end gap-5">
<Button size="sm" variant="outline" startIcon={<DocsIcon />} onClick={handleClickDownload}> </Button>
{ isMobileMode && <Button size="sm" variant="outline" startIcon={<FilterIcon />} onClick={() => setIsSearchFieldOpen(true)}></Button> }
<Button size="sm" variant="primary" onClick={handleClickSearch} disabled={isLoadingGlobal}></Button>
</div>
</div>
<div className="flex items-center justify-end gap-5">
{ isTreeOpen && <Checkbox checked={isGroupAccess} onChange={(value) => handleClickGroupAccess(value)} label="그룹단위로 조회" /> }
<PageCountSelector defaultValue={COUNT_PER_PAGE.toString()} onChange={(e) => setCOUNT_PER_PAGE(parseInt(e.target.value))} />
</div>
{ isMobileMode &&
<CtSelectedTextField label={"필터:"} listText={listSelectedText} setListText={setListSelectedText}></CtSelectedTextField>
}
<CtTable1 columns={columns} bodyData={tableData}
offset={tableOffset} totalCount={tableTotalCount} countPerPage={COUNT_PER_PAGE} pageNumCount={PAGE_NUM_COUNT}
onClickPageChange={handlePageChange} />
</ComponentCard>
<ErrorDetailModal multiState={modalData} items={modalItems} onOk={handleModalOk} isOpen={isOpen} closeModal={closeModal} />
</div>
</div>
);
}
@@ -0,0 +1,121 @@
"use client";
import React from "react";
import CtModal from "@/components/CtModal";
import Badge from "@/components/ui/badge/Badge";
import { useMultiState } from "@/hooks/useMultiState";
export interface ErrorDetailModalProps {
device_error_id: number;
reg_time: string;
device_name: string;
uid1: string;
uid1_type: number;
type: string;
error_type: number;
error_code: string;
biz_group_name: string;
}
/** One decoded line of the telegram, built by /api/get-device-error-detail.do */
export interface DeviceErrorDetailItem {
detail: string;
goods_code: string | null;
goods_name: string | null;
state: string;
}
interface ViewErrorDetailModalProps {
multiState: ReturnType<typeof useMultiState<ErrorDetailModalProps>>;
items: DeviceErrorDetailItem[];
onOk: () => void;
isOpen: boolean;
closeModal: () => void;
}
const pad2 = (value: number) => String(value).padStart(2, "0");
const splitRegTime = (reg_time: string) => {
if (!reg_time)
return { date: "", time: "" };
const d = new Date(reg_time);
if (isNaN(d.getTime()))
return { date: reg_time, time: "" };
return {
date: d.getFullYear() + "-" + pad2(d.getMonth() + 1) + "-" + pad2(d.getDate()),
time: pad2(d.getHours()) + ":" + pad2(d.getMinutes()) + ":" + pad2(d.getSeconds()),
};
};
const goodsText = (item: DeviceErrorDetailItem) => {
if (!item.goods_name)
return "-";
return item.goods_name + (item.goods_code ? " (" + item.goods_code + ")" : "");
};
const HeaderField = ({ label, value }: { label: string; value: React.ReactNode }) => (
<div className="flex items-center gap-2">
<span className="shrink-0 text-sm text-gray-500 dark:text-gray-400">{label}</span>
<span className="min-w-0 flex-1 truncate rounded-lg border border-gray-200 bg-gray-50 px-3 py-1.5 text-sm text-gray-800 dark:border-gray-800 dark:bg-white/[0.03] dark:text-white/90">
{value !== null && value !== undefined && value !== "" ? value : "-"}
</span>
</div>
);
export default function ErrorDetailModal({ multiState, items, onOk, isOpen, closeModal }: ViewErrorDetailModalProps) {
const values = multiState.values;
const { date, time } = splitRegTime(values.reg_time);
return (
<CtModal
title="자판기 상세내역"
isOpen={isOpen}
closeModal={closeModal}
className="max-w-[700px] p-6 lg:p-10"
>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<HeaderField label="발생일자" value={date} />
<HeaderField label="발생시간" value={time} />
<HeaderField label="무인기기명" value={values.device_name} />
<HeaderField label="단말기 TID" value={values.uid1 ? values.uid1 + (values.uid1_type ? " (" + (values.uid1_type == 1 ? "KICC" : "NICE") + ")" : "") : ""} />
<HeaderField label="그룹(사업자)" value={values.biz_group_name} />
</div>
<div className="mt-4 overflow-hidden rounded-lg border border-gray-200 dark:border-gray-800">
<div className="max-h-[320px] overflow-y-auto custom-scrollbar">
<table className="w-full">
<thead className="sticky top-0 bg-gray-50 dark:bg-white/[0.03]">
<tr>
<th className="border-b border-gray-200 px-4 py-2.5 text-center text-theme-sm font-medium text-gray-700 dark:border-gray-800 dark:text-gray-300"> </th>
<th className="border-b border-gray-200 px-4 py-2.5 text-center text-theme-sm font-medium text-gray-700 dark:border-gray-800 dark:text-gray-300">()</th>
<th className="w-28 border-b border-gray-200 px-4 py-2.5 text-center text-theme-sm font-medium text-gray-700 dark:border-gray-800 dark:text-gray-300"> </th>
</tr>
</thead>
<tbody>
{ items.length === 0 &&
<tr>
<td colSpan={3} className="px-4 py-10 text-center text-theme-sm text-gray-500 dark:text-gray-400">
.
</td>
</tr>
}
{ items.map((item, index) => (
<tr key={index} className="border-b border-gray-100 last:border-0 dark:border-gray-800">
<td className="px-4 py-2.5 text-center text-theme-sm text-gray-800 dark:text-white/90">{item.detail}</td>
<td className="px-4 py-2.5 text-center text-theme-sm text-gray-600 dark:text-gray-400">{goodsText(item)}</td>
<td className="px-4 py-2.5 text-center">
<Badge size="sm" color={item.state === "품절" ? "warning" : "error"}>{item.state}</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</CtModal>
);
};
@@ -0,0 +1,15 @@
import DeviceError from './DeviceError';
import { Metadata } from "next";
import React from "react";
export const metadata: Metadata = {
title: "무인기기 ERROR/품절내역",
description: "무인기기 ERROR/품절내역",
};
export default function DeviceErrorPage() {
return (
<DeviceError />
);
}
+3 -24
View File
@@ -1,11 +1,6 @@
import type { Metadata } from "next";
import { EcommerceMetrics } from "@/components/ecommerce/EcommerceMetrics";
import React from "react";
import MonthlyTarget from "@/components/ecommerce/MonthlyTarget";
import MonthlySalesChart from "@/components/ecommerce/MonthlySalesChart";
import StatisticsChart from "@/components/ecommerce/StatisticsChart";
import RecentOrders from "@/components/ecommerce/RecentOrders";
import DemographicCard from "@/components/ecommerce/DemographicCard";
import Dashboard from "./Dashboard";
export const metadata: Metadata = {
title:
@@ -13,24 +8,8 @@ export const metadata: Metadata = {
description: "한동정보통신 스마트서비스",
};
export default function Dashboard() {
export default function DashboardPage() {
return (
<div className="grid grid-cols-12 gap-4 md:gap-6">
<div className="col-span-12 space-y-6 xl:col-span-7">
<EcommerceMetrics />
<MonthlySalesChart />
</div>
<div className="col-span-12 xl:col-span-5">
<MonthlyTarget />
</div>
<div className="col-span-12">
<StatisticsChart />
</div>
</div>
<Dashboard />
);
}
@@ -0,0 +1,71 @@
"use client"
import PageBreadcrumb from "@/components/common/PageBreadCrumb2";
import ComponentCard from '@/components/common/ComponentCard2';
import { PageIcon } from "@/icons";
import React from "react";
import { COMPANY_NAME, EFFECTIVE_DATE, SERVICE_NAME, TERMS_ARTICLES, TermsArticle } from "./termsData";
const CIRCLED = ["①", "②", "③", "④", "⑤", "⑥", "⑦", "⑧", "⑨", "⑩"];
const Article = ({ article }: { article: TermsArticle }) => (
<section className="border-b border-gray-100 py-5 last:border-0 dark:border-gray-800">
<h3 className="mb-3 text-base font-semibold text-gray-800 dark:text-white/90">{article.title}</h3>
{ article.paragraphs?.map((text, index) => (
<p key={index} className="mb-3 text-sm leading-relaxed text-gray-700 dark:text-gray-300">{text}</p>
))}
{ article.items && article.items.length > 0 &&
<ol className="space-y-2">
{ article.items.map((text, index) => (
<li key={index} className="text-sm leading-relaxed text-gray-700 dark:text-gray-300">
<span className="mr-1.5 text-gray-500 dark:text-gray-400">{index + 1}.</span>
{text}
{ article.subItems?.[index] &&
<ul className="mt-2 space-y-1.5 pl-5">
{ article.subItems[index].map((sub, subIndex) => (
<li key={subIndex} className="text-sm leading-relaxed text-gray-600 dark:text-gray-400">
<span className="mr-1.5">{CIRCLED[subIndex] ?? "-"}</span>
{sub}
</li>
))}
</ul>
}
</li>
))}
</ol>
}
</section>
);
export default function Terms() {
return (
<div>
<PageBreadcrumb pageTitle1="게시판" pageTitle2="이용약관" />
<ComponentCard title={SERVICE_NAME + " 이용약관"} titleIcon={<PageIcon />}>
<div className="rounded-lg border border-gray-200 bg-gray-50 p-4 text-sm text-gray-600 dark:border-gray-800 dark:bg-white/[0.03] dark:text-gray-400">
{COMPANY_NAME}( &lsquo;&rsquo;) {SERVICE_NAME} , · .
<br />
: {EFFECTIVE_DATE}
</div>
<div className="mt-2">
{ TERMS_ARTICLES.map((article, index) => (
<Article key={index} article={article} />
))}
<section className="pt-5">
<h3 className="mb-3 text-base font-semibold text-gray-800 dark:text-white/90"></h3>
<p className="text-sm leading-relaxed text-gray-700 dark:text-gray-300">
{EFFECTIVE_DATE} .
</p>
</section>
</div>
</ComponentCard>
</div>
);
}
@@ -0,0 +1,15 @@
import Terms from './Terms';
import { Metadata } from "next";
import React from "react";
export const metadata: Metadata = {
title: "이용약관",
description: "이용약관",
};
export default function TermsPage() {
return (
<Terms />
);
}
@@ -0,0 +1,195 @@
// 한동스마트서비스 이용약관
// 이지샵(www.easyshop.co.kr) 서비스 이용약관의 구성을 참고하여, 무인기기 관리 서비스에 맞게
// 조항을 재구성한 것이다. 시행일과 세부 요금/환불 정책은 운영 정책에 맞추어 조정한다.
export const COMPANY_NAME = "한동정보통신";
export const SERVICE_NAME = "한동스마트서비스";
export const EFFECTIVE_DATE = "2026년 9월 10일";
export type TermsArticle = {
title: string;
paragraphs?: string[]; // 번호 없는 본문
items?: string[]; // 1. 2. 3. 형태의 항
subItems?: { [index: number]: string[] }; // 해당 항 아래의 ① ② ③ 목
};
export const TERMS_ARTICLES: TermsArticle[] = [
{
title: "제1조 (목적)",
paragraphs: [
`이 약관은 ${COMPANY_NAME}(이하 '회사'라 합니다)가 제공하는 ${SERVICE_NAME}(이하 '서비스'라 합니다)를 이용함에 있어 회사와 이용회원(이하 '회원'이라 합니다) 간의 권리·의무 및 책임사항, 기타 필요한 사항을 규정함을 목적으로 합니다.`,
],
},
{
title: "제2조 (용어의 정의)",
paragraphs: ["이 약관에서 사용하는 용어의 정의는 다음과 같습니다."],
items: [
"'서비스'라 함은 회사가 웹 및 모바일 환경을 통하여 제공하는 무인기기 원격관리 서비스를 말하며, 무인기기 및 카드단말기 등록·관리, 거래내역 조회, 매출 집계, 상품 및 재고 관리, 기기 이상·품절 내역 조회, 알림 발송 등을 포함합니다.",
"'회원'이라 함은 이 약관에 동의하고 회사와 이용계약을 체결하여 서비스를 이용하는 사업자 또는 그 사업자가 지정한 사용자를 말합니다.",
"'무인기기'라 함은 회원이 운영하는 자동판매기 등 회사의 서비스에 등록된 기기를 말합니다.",
"'단말기'라 함은 무인기기에 연결되어 결제를 처리하는 카드단말기를 말하며, 단말기 고유번호(TID)로 식별합니다.",
"'계정'이라 함은 회원의 식별과 서비스 이용을 위하여 회사가 부여한 아이디와 비밀번호의 조합을 말합니다.",
"'사업자 그룹'이라 함은 회원이 운영하는 사업장을 상위·하위 단위로 구성한 관리 단위를 말하며, 회원은 자신에게 부여된 그룹 범위 내의 정보만 조회할 수 있습니다.",
],
},
{
title: "제3조 (약관의 효력 및 변경)",
items: [
"이 약관은 서비스를 이용하고자 하는 자가 그 내용에 동의함으로써 효력이 발생합니다.",
"회사는 관련 법령을 위배하지 않는 범위에서 이 약관을 개정할 수 있습니다.",
"회사가 약관을 개정하는 경우에는 적용일자 및 개정사유를 명시하여 적용일자 15일 전부터 서비스 화면에 공지합니다. 다만 회원에게 불리한 변경인 경우에는 적용일자 30일 전부터 공지하고, 전자우편 또는 문자메시지 등으로 개별 통지합니다.",
"회사가 전항의 공지 또는 통지를 하면서 적용일자까지 회원이 거부의사를 표시하지 아니하면 개정에 동의한 것으로 본다는 뜻을 명확히 고지하였음에도 회원이 명시적으로 거부의사를 표시하지 아니한 경우, 회원은 개정약관에 동의한 것으로 봅니다.",
"개정약관에 동의하지 아니하는 회원은 이용계약을 해지할 수 있습니다.",
"이 약관에 명시되지 아니한 사항은 전자상거래 등에서의 소비자보호에 관한 법률, 약관의 규제에 관한 법률, 정보통신망 이용촉진 및 정보보호 등에 관한 법률 등 관련 법령 또는 상관례에 따릅니다.",
],
},
{
title: "제4조 (이용계약의 성립 및 승낙의 제한)",
items: [
"이용계약은 서비스를 이용하고자 하는 자가 이 약관에 동의하고 회사가 정한 절차에 따라 이용을 신청한 후, 회사가 이를 승낙함으로써 성립합니다.",
"회사는 다음 각 호에 해당하는 신청에 대하여는 승낙을 하지 아니하거나 사후에 이용계약을 해지할 수 있습니다.",
"회원은 이용 신청 시 회사가 요구하는 사업자 정보와 기기 정보를 사실과 일치하도록 기재하여야 하며, 기재사항이 변경된 경우 지체 없이 이를 갱신하여야 합니다.",
"회원이 정보를 사실과 다르게 기재함으로써 발생한 불이익에 대하여 회사는 책임을 지지 아니합니다.",
],
subItems: {
1: [
"타인의 명의 또는 사업자 정보를 도용하거나 허위로 기재한 경우",
"이용 신청자가 이 약관에 따라 이전에 이용계약이 해지된 사실이 있는 경우",
"서비스 이용 요금을 정당한 사유 없이 납부하지 아니한 경우",
"회사의 설비 여유가 없거나 기술상 지장이 있는 경우",
"기타 회사가 정한 이용 신청 요건을 충족하지 못한 경우",
],
},
},
{
title: "제5조 (서비스의 내용)",
items: [
"회사는 회원에게 다음 각 호의 서비스를 제공합니다.",
"회사는 서비스의 품질 향상, 운영상·기술상의 필요에 따라 제공하는 서비스의 내용을 변경하거나 새로운 서비스를 추가할 수 있으며, 이 경우 변경 내용과 적용일자를 사전에 공지합니다.",
],
subItems: {
0: [
"무인기기 및 카드단말기 등록·조회·관리",
"거래내역 조회 및 영수증 확인",
"일별·기간별 매출 집계 및 통계 조회",
"상품 정보, 컬럼별 상품 배치 및 재고 관리",
"무인기기 이상(ERROR) 및 품절 내역 조회",
"장애·품절 알림 등 문자메시지 발송",
"사업자 그룹 및 사용자 계정 관리",
"기타 회사가 정하는 부가 서비스",
],
},
},
{
title: "제6조 (서비스의 제공 시간 및 중단)",
items: [
"서비스는 연중무휴 1일 24시간 제공함을 원칙으로 합니다.",
"회사는 시스템 점검, 설비의 보수·교체, 서비스 개선 등의 사유가 있는 경우 서비스의 전부 또는 일부를 일시 중단할 수 있으며, 이 경우 중단 사유와 기간을 사전에 공지합니다. 다만 긴급한 사유가 있는 경우에는 사후에 공지할 수 있습니다.",
"천재지변, 정전, 통신망 장애, 카드사·부가통신사업자 등 제휴사의 시스템 장애 등 회사가 통제할 수 없는 사유로 서비스를 제공할 수 없는 경우 회사는 서비스 제공 의무를 면합니다.",
"무인기기 또는 단말기의 통신 상태에 따라 거래내역 및 기기 상태 정보의 수집이 지연될 수 있으며, 이 경우 회사는 정상화를 위하여 필요한 조치를 취합니다.",
],
},
{
title: "제7조 (서비스 이용의 제한)",
items: [
"회사는 회원이 다음 각 호에 해당하는 경우 사전 통지 후 서비스 이용을 제한하거나 이용계약을 해지할 수 있습니다. 다만 긴급을 요하는 경우에는 조치 후 통지할 수 있습니다.",
"회사가 전항에 따라 이용을 제한하는 경우, 회원은 그 사유가 발생한 날부터 10일 이내에 서면 또는 전자우편으로 이의를 제기할 수 있으며, 회사는 이를 접수한 날부터 10일 이내에 처리 결과를 회신합니다.",
],
subItems: {
0: [
"서비스의 안정적 운영을 방해할 목적으로 대량의 정보를 전송하거나 시스템에 부하를 유발하는 경우",
"다른 회원의 계정 또는 비밀번호를 부정하게 사용하는 경우",
"서비스를 통하여 취득한 정보를 회사의 사전 승낙 없이 제3자에게 제공하거나 영리 목적으로 이용하는 경우",
"서비스를 이용하여 법령에 위반되는 행위를 하거나 이를 교사·방조하는 경우",
"이 약관에서 정한 회원의 의무를 위반한 경우",
],
},
},
{
title: "제8조 (유료 서비스 및 이용요금)",
items: [
"회사가 제공하는 서비스는 무료 서비스와 유료 서비스로 구분되며, 유료 서비스의 종류와 요금은 회사와 회원 간의 개별 계약 또는 서비스 화면에 게시된 내용에 따릅니다.",
"문자메시지 발송 등 사용량에 따라 과금되는 서비스의 요금은 실제 사용량을 기준으로 산정합니다.",
"회사는 이용요금을 변경하는 경우 제3조에서 정한 방법에 따라 사전에 공지합니다.",
"회원이 이용요금을 납부기일까지 납부하지 아니하는 경우 회사는 유료 서비스의 제공을 중단할 수 있습니다.",
],
},
{
title: "제9조 (계약의 해지 및 환불)",
items: [
"회원은 언제든지 회사가 정한 절차에 따라 이용계약의 해지를 신청할 수 있으며, 회사는 관련 법령이 정하는 바에 따라 이를 지체 없이 처리합니다.",
"회원이 유료 서비스를 전혀 이용하지 아니한 경우, 결제일부터 7일 이내에 결제 취소를 요청할 수 있습니다.",
"이미 제공된 서비스에 해당하는 요금은 환불되지 아니하며, 잔여 기간에 대한 요금은 회사가 정한 기준에 따라 일할 계산하여 환불합니다.",
"환불은 회원이 결제한 수단과 동일한 방법으로 처리함을 원칙으로 하며, 동일한 방법으로 환불이 불가능한 경우 회사는 사전에 그 사유를 안내하고 다른 방법으로 환불합니다.",
"회원의 약관 위반으로 회사가 이용계약을 해지하는 경우, 회사는 회원에게 발생한 손해에 대한 배상을 청구할 수 있습니다.",
],
},
{
title: "제10조 (과오금)",
items: [
"회사는 이용요금의 결제와 관련하여 과오금이 발생한 경우 결제와 동일한 방법으로 과오금 전액을 환불합니다. 동일한 방법으로 환불이 불가능한 경우에는 사전에 이를 안내합니다.",
"회사의 귀책사유로 과오금이 발생한 경우 회사가 그 환불에 소요되는 비용을 부담하며, 회원의 귀책사유로 과오금이 발생한 경우에는 합리적인 범위에서 회원이 부담합니다.",
"회사가 회원의 과오금 환불 요청을 거부하는 경우, 회사는 이용요금이 정당하게 부과되었음을 입증할 책임을 부담합니다.",
],
},
{
title: "제11조 (공지 및 정보의 제공)",
items: [
"회사는 서비스 운영과 관련한 공지사항을 서비스 화면에 게시하거나 전자우편, 문자메시지, 푸시 알림 등의 방법으로 회원에게 통지할 수 있습니다.",
"회사는 무인기기의 장애·품절 등 운영에 필요한 정보를 회원이 등록한 연락처로 발송할 수 있습니다.",
"광고성 정보는 사전에 수신에 동의한 회원에게만 발송하며, 회원은 언제든지 수신 동의를 철회할 수 있습니다.",
],
},
{
title: "제12조 (개인정보의 보호)",
items: [
"회사는 서비스 제공에 필요한 최소한의 범위에서 회원의 개인정보를 수집하며, 수집한 개인정보는 관련 법령과 회사의 개인정보처리방침에 따라 보호·관리합니다.",
"회사는 회원의 동의 없이 개인정보를 제3자에게 제공하지 아니합니다. 다만 법령에 따라 요구되는 경우에는 그러하지 아니합니다.",
"회원은 자신의 개인정보에 대한 열람·정정·삭제 및 처리정지를 언제든지 요구할 수 있습니다.",
"개인정보의 처리에 관한 상세한 사항은 회사의 개인정보처리방침에 따릅니다.",
],
},
{
title: "제13조 (회원의 의무)",
items: [
"회원은 관계 법령, 이 약관, 서비스 이용안내 및 회사가 공지한 사항을 준수하여야 하며, 회사의 업무를 방해하는 행위를 하여서는 아니 됩니다.",
"회원은 자신의 계정과 비밀번호를 관리할 책임이 있으며, 이를 제3자에게 이용하게 하여서는 아니 됩니다. 관리 소홀로 발생한 결과에 대한 책임은 회원에게 있습니다.",
"회원은 계정 정보가 도용되거나 제3자가 무단으로 사용하고 있음을 인지한 경우 즉시 회사에 통지하고 회사의 안내에 따라야 합니다.",
"회원은 서비스를 통하여 조회한 거래내역, 매출정보 등을 서비스 이용 목적 외의 용도로 사용하여서는 아니 됩니다.",
"회원은 무인기기 및 단말기 정보를 사실과 일치하도록 등록·관리하여야 하며, 기기의 교체·폐기 등 변경사항을 지체 없이 반영하여야 합니다.",
],
},
{
title: "제14조 (회사의 의무)",
items: [
"회사는 관련 법령과 이 약관을 준수하며, 계속적이고 안정적으로 서비스를 제공하기 위하여 최선을 다합니다.",
"회사는 회원의 개인정보 보호를 위한 보안 시스템을 갖추고 개인정보처리방침을 공시하고 준수합니다.",
"회사는 서비스 이용과 관련하여 회원이 제기한 의견이나 불만이 정당하다고 인정되는 경우 이를 처리하며, 처리 과정과 결과를 회원에게 안내합니다.",
],
},
{
title: "제15조 (손해배상 및 면책)",
items: [
"회사는 다음 각 호의 사유로 회원 또는 제3자에게 발생한 손해에 대하여 책임을 지지 아니합니다.",
"회사는 회원이 서비스를 이용하여 기대하는 수익을 얻지 못하였거나, 서비스를 통하여 조회한 정보를 신뢰함으로써 발생한 손해에 대하여 책임을 지지 아니합니다.",
"회사는 회원 상호간 또는 회원과 제3자 간에 발생한 분쟁에 개입할 의무가 없으며, 회사의 귀책사유가 없는 한 이로 인한 손해를 배상할 책임이 없습니다.",
"회원이 이 약관을 위반하여 회사에 손해를 입힌 경우, 회원은 회사에 그 손해를 배상하여야 합니다.",
],
subItems: {
0: [
"천재지변 또는 이에 준하는 불가항력으로 서비스를 제공할 수 없는 경우",
"회원이 계정 또는 비밀번호의 관리를 소홀히 한 경우",
"회사의 관리 영역이 아닌 통신망의 장애로 서비스 이용이 불가능한 경우",
"무인기기 또는 단말기 자체의 고장·오작동으로 인한 경우",
"기타 회사의 귀책사유가 없는 사유로 인한 경우",
],
},
},
{
title: "제16조 (분쟁의 해결)",
items: [
"회사와 회원은 서비스와 관련하여 분쟁이 발생한 경우 원만한 해결을 위하여 성실히 협의합니다.",
"협의가 이루어지지 아니한 경우, 회사의 본점 소재지를 관할하는 법원을 제1심 관할법원으로 합니다.",
],
},
];
@@ -27,6 +27,49 @@ import PageCountSelector from "@/components/PageCountSelector";
import InputSelectField from "@/components/form/input/InputSelectField";
const PAY_TYPE_LABEL: { [key: string]: string } = {
D1: "신용승인", D4: "신용취소",
I1: "페이승인", I4: "페이취소",
B1: "현금",
TM1: "티머니승인", TM4: "티머니취소",
EB1: "캐시비승인", EB4: "캐시비취소",
};
// A credit cancel carries rtid, so the list also brings down the original approval
const isCreditCancel = (item: any) => item.type === "D4" && item.rtid;
// Only the first 6 digits are stored: pad to 4-4-4-4 and mask the unknown digits
const formatCardNum = (card_num: any) => {
const digits = String(card_num ?? "").replace(/[^0-9]/g, "");
if (digits === "")
return "";
const filled = (digits + "****************").substring(0, 16);
return (filled.match(/.{1,4}/g) ?? []).join("-");
};
const toAmount = (amount: any) => (amount === null || amount === undefined ? "" : Number(amount).toLocaleString("ko-KR") + "원");
const payCardName = (type: string, pay_name: string) => (
type === "TM1" || type === "TM4" ? "티머니" :
type === "EB1" || type === "EB4" ? "캐시비" :
pay_name
);
/** A credit cancel is drawn as two lines, the dim second line being the original approval */
const cell = (item: any, main: React.ReactNode, sub?: React.ReactNode) => {
if (!isCreditCancel(item))
return main;
return (
<>
<div>{main}</div>
<div className="mt-1 text-gray-400 dark:text-gray-500">{sub !== undefined && sub !== null && sub !== "" ? sub : " "}</div>
</>
);
};
const SEARCH_LABEL_WIDTH_PX = 80;
//const COUNT_PER_PAGE = 10;
const PAGE_NUM_COUNT = 5;
@@ -240,13 +283,22 @@ export default function Transactions() {
};
const handleRowClickReceipt = (row: number) => {
const node = findRootNodeByKey(treeData, String(tokenPayload?.biz_group_id));
modalData.setAll({...tableData[row],
biz_group_name: node.name,
biz_reg_num: node.biz_reg_num,
ceo: node.ceo,
phone: node.phone,
address: node.address,
// The receipt belongs to the group being listed, not to the signed-in account:
// a super admin browses other groups, so its own biz_group_id is not in the tree.
const selectedKey = selectedTreeKeys[0];
const node = selectedKey != undefined ? findRootNodeByKey(treeData, String(selectedKey)) : null;
if (node == null) {
alert("사업자 정보를 찾을 수 없습니다. 좌측에서 사업자를 선택한 후 다시 시도하세요.");
return;
}
modalData.setAll({...tableData[row],
biz_group_name: node.name ?? "",
biz_reg_num: node.biz_reg_num ?? "",
ceo: node.ceo ?? "",
phone: node.phone ?? "",
address: node.address ?? "",
});
openModal();
};
@@ -374,28 +426,30 @@ export default function Transactions() {
{
title: "거래시간",
key: "order_time",
renderItem: (item: any, row: number) => convertDateTime6(item.order_time),
renderItem: (item: any, row: number) => cell(item, convertDateTime6(item.order_time), convertDateTime6(item.org_order_time)),
viewMobile: true,
},
{
title: "무인기기명",
key: "device_name",
renderItem: (item: any) => cell(item, item.device_name),
},
{
title: "단말기 TID",
key: "uid1",
renderItem: (item: any) => cell(item, item.uid1),
viewMobile: true,
},
{
title: "금액",
key: "amount",
renderItem: (item : any) => (item.amount.toLocaleString("ko-KR") + "원"),
renderItem: (item : any) => cell(item, toAmount(item.amount), toAmount(item.org_amount)),
viewMobile: true,
},
{
title: "결제유형",
key: "type",
renderItem: (item : any) => (
renderItem: (item : any) => cell(item,
<Badge size="sm" color={
item.type === "D1" ? "primary" :
item.type === "I1" ? "info" :
@@ -404,46 +458,40 @@ export default function Transactions() {
item.type === "EB1" ? "success" :
"error"
}>
{item.type === "D1" ? "신용승인" :
item.type === "D4" ? "신용취소" :
item.type === "I1" ? "페이승인" :
item.type === "I4" ? "페이취소" :
item.type === "B1" ? "현금" :
item.type === "TM1" ? "티머니승인" :
item.type === "TM4" ? "티머니취소" :
item.type === "EB1" ? "캐시비승인" :
item.type === "EB4" ? "캐시비취소" :
""}
</Badge>
{PAY_TYPE_LABEL[item.type] ?? ""}
</Badge>,
PAY_TYPE_LABEL[item.org_type] ?? ""
)
},
{
title: "승인번호",
key: "approval",
renderItem: (item: any) => cell(item, item.approval, item.org_approval),
},
{
title: "카드번호",
key: "card_num",
renderItem: (item: any) => cell(item, formatCardNum(item.card_num), formatCardNum(item.org_card_num)),
},
{
title: "컬럼번호",
key: "column_no",
renderItem: (item: any) => cell(item, item.column_no, item.org_column_no),
},
{
title: "상품코드",
key: "code",
renderItem: (item: any) => cell(item, item.code, item.org_code),
},
{
title: "상품명",
key: "goods_name",
renderItem: (item: any) => cell(item, item.goods_name, item.org_goods_name),
},
{
title: "결제카드",
key: "pay_name",
renderItem: (item : any) => (
item.type === "TM1" ? "티머니" :
item.type === "TM4" ? "티머니" :
item.type === "EB1" ? "캐시비" :
item.type === "EB4" ? "캐시비" :
item.pay_name
)
renderItem: (item : any) => cell(item, payCardName(item.type, item.pay_name), payCardName(item.org_type, item.org_pay_name))
},
{
title: "영수증",
@@ -20,6 +20,28 @@ export default function SignInForm() {
const [user_pw, setUserPw] = useState("");
const [showPassword, setShowPassword] = useState(false);
const [isChecked, setIsChecked] = useState(false);
const [isSendingSms, setIsSendingSms] = useState(false); //TempCode: SMS integration test
// TempCode: SMS integration test, remove when done
const handleSendSmsTest = () => {
setIsSendingSms(true);
api.post('/api/send-sms-test.do')
.then((resp) => {
console.log(resp);
const r = resp.data;
if (r.code === "0000")
alert("문자를 전송했습니다.\n수신번호: " + r.phone + "\n잔여건수: " + r.remainCount);
else
alert("문자 전송에 실패했습니다.\n코드: " + r.code + "\n메시지: " + r.msg);
})
.catch((err) => {
console.error(err);
alert("문자 전송 요청에 실패했습니다.");
})
.finally(() => setIsSendingSms(false));
};
const handleLogin = () => {
//router.push("/"); //TestCode
@@ -124,6 +146,12 @@ export default function SignInForm() {
</Button>
</div>
{/* TempCode: SMS integration test, remove when done */}
<div>
<Button className="w-full" size="sm" variant="outline" onClick={handleSendSmsTest} disabled={isSendingSms}>
{isSendingSms ? "문자 전송 중..." : "문자전송 테스트"}
</Button>
</div>
</div>
{/*</form>*/}
@@ -73,6 +73,11 @@ const othersItems: NavItem[] = [
name: "무인기기 관리",
path: "/device",
},
{
icon: <DocsIcon />,
name: "무인기기 ERROR/품절내역",
path: "/device-error",
},
{
icon: <PieChartIcon />,
name: "매출집계",
@@ -94,6 +99,7 @@ const othersItems: NavItem[] = [
subItems: [
{ name: "공지사항", path: "/notice" },
{ name: "FAQ", path: "/faq" },
{ name: "이용약관", path: "/terms" },
//{ name: "자료실", path: "/resource-board" },
//{ name: "VOC", path: "/voc" },
],
@@ -0,0 +1,13 @@
// Labels for device_error.error_type.
// The telegram itself (device_error.error_code) is parsed on the server:
// see component/DeviceErrorCode.java and /api/get-device-error-detail.do
export const DEVICE_ERROR_TYPE_FAULT = 1; // 단말기 이상
export const DEVICE_ERROR_TYPE_SOLD_OUT = 2; // 품절
export const deviceErrorTypeLabel = (error_type: number | string | null | undefined) => {
const value = Number(error_type);
if (value === DEVICE_ERROR_TYPE_FAULT) return "단말기 이상";
if (value === DEVICE_ERROR_TYPE_SOLD_OUT) return "품절";
return "";
};