nation 버전 이식

This commit is contained in:
Macbook
2026-07-12 15:55:08 +09:00
parent 314a3554d3
commit 07a8114927
54 changed files with 11612 additions and 203 deletions
@@ -0,0 +1,683 @@
package com.bizcare.agent;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.stream.Collectors;
/**
* 영업점관리 — billing_react BranchController / BranchGroupController / BranchFee entries 이식.
*/
@RestController
@RequestMapping("/api/agent/homes")
@RequiredArgsConstructor
class CareBranchController {
private static final ZoneId KST = ZoneId.of("Asia/Seoul");
private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final EntityManager em;
private final PasswordEncoder encoder;
// ---- 영업점 그룹 ----
@GetMapping("/branch-groups")
@Transactional(readOnly = true)
ApiResponse branchGroups(@AuthenticationPrincipal JwtUser u) {
List<HomesBranchGroup> list = em.createQuery(
"select g from HomesBranchGroup g where g.groupId=:gid order by g.seq asc, g.id asc",
HomesBranchGroup.class)
.setParameter("gid", u.getGroupId())
.getResultList();
return ApiResponse.ok(list.stream().map(this::groupMap).toList());
}
@PostMapping("/branch-groups")
@Transactional
ApiResponse createBranchGroup(@RequestBody Map<String, Object> body, @AuthenticationPrincipal JwtUser u) {
String name = str(body.get("name"));
if (blank(name)) return ApiResponse.fail("그룹명을 입력하세요.");
HomesBranchGroup g = new HomesBranchGroup();
g.setGroupId(u.getGroupId());
g.setName(name.trim());
g.setSeq(body.get("seq") instanceof Number n ? n.intValue() : 0);
em.persist(g);
return ApiResponse.ok(groupMap(g));
}
// ---- 영업점 목록 / CRUD ----
@GetMapping("/branches")
@Transactional(readOnly = true)
ApiResponse branches(@AuthenticationPrincipal JwtUser u) {
List<HomesBranch> list = em.createQuery(
"select b from HomesBranch b where b.groupId=:gid order by b.id desc",
HomesBranch.class)
.setParameter("gid", u.getGroupId())
.getResultList();
Map<Long, String> groupNames = groupNameMap(u.getGroupId());
return ApiResponse.ok(list.stream().map(b -> listMap(b, groupNames)).toList());
}
@PostMapping("/branches")
@Transactional
ApiResponse createBranch(@RequestBody Map<String, Object> body,
@RequestParam(defaultValue = "1") int level,
@RequestParam(required = false) Long branchGroupId,
@AuthenticationPrincipal JwtUser u) {
String userid = str(body.get("userid"));
String password = str(body.get("password"));
String officeName = str(body.get("officeName"));
String name = str(body.get("name"));
String phone = str(body.get("phone"));
String email = str(body.get("email"));
if (blank(userid) || blank(password)) return ApiResponse.fail("아이디와 패스워드를 입력하세요.");
if (blank(officeName) || blank(name)) return ApiResponse.fail("영업점명과 대표자명을 입력하세요.");
if (blank(phone) || blank(email)) return ApiResponse.fail("휴대폰과 이메일을 입력하세요.");
if (existsUserid(u.getGroupId(), userid, null)) return ApiResponse.fail("다른사람이 사용중인 아이디입니다.");
if (existsOfficeName(u.getGroupId(), officeName, null)) return ApiResponse.fail("다른사람이 사용중인 영업점명입니다.");
if (existsEmail(u.getGroupId(), email, null)) return ApiResponse.fail("다른 사람이 사용중인 이메일입니다.");
HomesBranch b = new HomesBranch();
b.setGroupId(u.getGroupId());
b.setUserid(userid.trim());
b.setPasswordHash(encoder.encode(password));
b.setName(name.trim());
b.setOfficeName(officeName.trim());
b.setTel(str(body.get("tel")));
b.setPhone(phone.trim());
b.setFax(str(body.get("fax")));
b.setEmail(email.trim());
b.setZipcode(str(body.get("zipcode")));
b.setAddress(str(body.get("address")));
b.setAddressDetail(str(body.get("addressDetail")));
b.setSido(deriveSido(str(body.get("zipcode")), str(body.get("sido")), str(body.get("address"))));
b.setLevel(level);
b.setGrantFlag(1); // 관리자 등록 시 승인
b.setBranchGroupId(branchGroupId != null ? branchGroupId : longOrNull(body.get("branchGroupId")));
b.setIsTax(body.get("isTax") instanceof Number n ? n.intValue() : 1);
b.setMemo(str(body.get("memo")));
em.persist(b);
return ApiResponse.ok(listMap(b, groupNameMap(u.getGroupId())));
}
@GetMapping("/branches/{id}/detail")
@Transactional(readOnly = true)
ApiResponse branchDetail(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) {
HomesBranch b = findBranch(id, u);
if (b == null) return ApiResponse.fail("영업점을 찾을 수 없습니다.");
return ApiResponse.ok(detailMap(b));
}
@PutMapping("/branches/{id}/detail")
@Transactional
ApiResponse updateBranchDetail(@PathVariable Long id, @RequestBody Map<String, Object> body,
@AuthenticationPrincipal JwtUser u) {
HomesBranch b = findBranch(id, u);
if (b == null) return ApiResponse.fail("영업점을 찾을 수 없습니다.");
String officeName = str(body.get("officeName"));
String email = str(body.get("email"));
String name = str(body.get("name"));
if (blank(officeName) || blank(name)) return ApiResponse.fail("영업점명과 이름을 입력하세요.");
if (existsOfficeName(u.getGroupId(), officeName, id)) return ApiResponse.fail("다른사람이 사용중인 영업점명입니다.");
if (!blank(email) && existsEmail(u.getGroupId(), email, id)) return ApiResponse.fail("다른 사람이 사용중인 이메일입니다.");
String password = str(body.get("password"));
String passwordConfirmation = str(body.get("passwordConfirmation"));
if (!blank(password)) {
if (!password.equals(passwordConfirmation)) return ApiResponse.fail("패스워드 확인이 일치하지 않습니다.");
b.setPasswordHash(encoder.encode(password));
}
Long oldGroupId = b.getBranchGroupId();
Long newGroupId = longOrNull(body.get("branchGroupId"));
boolean keepFee = Boolean.TRUE.equals(body.get("keepFee"))
|| "true".equalsIgnoreCase(String.valueOf(body.get("keepFee")));
b.setName(name.trim());
b.setOfficeName(officeName.trim());
b.setTel(str(body.get("tel")));
b.setPhone(str(body.get("phone")));
b.setFax(str(body.get("fax")));
b.setEmail(email);
b.setZipcode(str(body.get("zipcode")));
b.setAddress(str(body.get("address")));
b.setAddressDetail(str(body.get("addressDetail")));
b.setSido(deriveSido(str(body.get("zipcode")), str(body.get("sido")), str(body.get("address"))));
if (body.get("level") instanceof Number n) b.setLevel(n.intValue());
b.setBranchGroupId(newGroupId);
b.setCenterName(str(body.get("centerName")));
if (blank(b.getCenterName()) && body.get("centerId") != null) {
// centerId 호환: 숫자면 무시, 문자열이면 이름
b.setCenterName(str(body.get("centerId")));
}
boolean isPersonal = Boolean.TRUE.equals(body.get("isPersonal"))
|| "true".equalsIgnoreCase(String.valueOf(body.get("isPersonal")));
b.setHasBusinessLicense(!isPersonal);
b.setLicenseNumber(isPersonal ? null : str(body.get("licenseNumber")));
b.setLicenseType(isPersonal ? null : str(body.get("licenseType")));
b.setLicenseItem(isPersonal ? null : str(body.get("licenseItem")));
b.setAccountBank(str(body.get("accountBank")));
b.setAccountNumber(str(body.get("accountNumber")));
boolean accountNameIsName = Boolean.TRUE.equals(body.get("accountNameIsName"))
|| "true".equalsIgnoreCase(String.valueOf(body.get("accountNameIsName")));
b.setAccountName(accountNameIsName ? b.getName() : str(body.get("accountName")));
b.setContactPhone(str(body.get("contactPhone")));
b.setContactNateon(str(body.get("contactNateon")));
if (body.get("acceptPhone") instanceof Number n) b.setAcceptPhone(n.intValue());
if (body.get("acceptNateon") instanceof Number n) b.setAcceptNateon(n.intValue());
if (body.get("isTax") instanceof Number n) b.setIsTax(n.intValue());
b.setMemo(str(body.get("memo")));
b.setAdminMemo(str(body.get("adminMemo")));
b.setNotifyPhonesJson(toNotifyJson(body.get("notifyPhones")));
b.setPermissionOrderDateUpdate(bool(body.get("permissionOrderDateUpdate")));
b.setPermissionCalculate(bool(body.get("permissionCalculate")));
b.setPermissionConfig(bool(body.get("permissionConfig")));
b.setPermissionExcelDownload(bool(body.get("permissionExcelDownload")));
b.setPermissionChart(bool(body.get("permissionChart")));
// keepFee=false 이고 그룹이 바뀌면 개별 수수료는 유지, 그룹 매핑은 이름만 바뀌므로 별도 처리 없음
if (!keepFee && !Objects.equals(oldGroupId, newGroupId) && newGroupId != null) {
HomesBranchGroup ng = em.find(HomesBranchGroup.class, newGroupId);
if (ng != null && Objects.equals(ng.getGroupId(), u.getGroupId())) {
// 그룹 수수료는 branchGroupName 문자열 키 — 접수의 branchGroup 과 맞추는 용도만
// (실제 접수 일괄 갱신은 범위 외)
}
}
return ApiResponse.ok(detailMap(b));
}
@PostMapping("/branches/{id}/grant/{grant}")
@Transactional
ApiResponse setGrant(@PathVariable Long id, @PathVariable int grant, @AuthenticationPrincipal JwtUser u) {
HomesBranch b = findBranch(id, u);
if (b == null) return ApiResponse.fail("영업점을 찾을 수 없습니다.");
if (grant != 0 && grant != 1) return ApiResponse.fail("승인값은 0 또는 1이어야 합니다.");
b.setGrantFlag(grant);
return ApiResponse.ok(null);
}
@DeleteMapping("/branches/{id}")
@Transactional
ApiResponse deleteBranch(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) {
HomesBranch b = findBranch(id, u);
if (b == null) return ApiResponse.fail("영업점을 찾을 수 없습니다.");
em.remove(b);
return ApiResponse.ok(null);
}
/** 영업점 수수료 목록 (개별 + 소속 그룹) — billing GET /branch-fee/branch/{id}/entries */
@GetMapping("/branches/{id}/fees")
@Transactional(readOnly = true)
ApiResponse branchFees(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) {
HomesBranch b = findBranch(id, u);
if (b == null) return ApiResponse.fail("영업점을 찾을 수 없습니다.");
List<Map<String, Object>> entries = new ArrayList<>();
List<HomesBranchFee> alone = em.createQuery(
"select f from HomesBranchFee f where f.groupId=:gid and f.feeType='alone' and f.branchName=:name",
HomesBranchFee.class)
.setParameter("gid", u.getGroupId())
.setParameter("name", b.getOfficeName())
.getResultList();
for (HomesBranchFee f : alone) {
entries.add(feeEntryMap(f, "alone"));
}
if (b.getBranchGroupId() != null) {
HomesBranchGroup g = em.find(HomesBranchGroup.class, b.getBranchGroupId());
if (g != null && Objects.equals(g.getGroupId(), u.getGroupId())) {
List<HomesBranchFee> groupFees = em.createQuery(
"select f from HomesBranchFee f where f.groupId=:gid and f.feeType='group' and f.branchGroupName=:name",
HomesBranchFee.class)
.setParameter("gid", u.getGroupId())
.setParameter("name", g.getName())
.getResultList();
for (HomesBranchFee f : groupFees) {
entries.add(feeEntryMap(f, "group"));
}
}
}
// 적용일 내림차순, 동일일이면 개별(alone) 우선
entries.sort((a, b2) -> {
String sa = String.valueOf(a.get("startDate") == null ? "" : a.get("startDate"));
String sb = String.valueOf(b2.get("startDate") == null ? "" : b2.get("startDate"));
int c = sb.compareTo(sa);
if (c != 0) return c;
int pa = "alone".equals(a.get("type")) ? 0 : 1;
int pb = "alone".equals(b2.get("type")) ? 0 : 1;
return Integer.compare(pa, pb);
});
return ApiResponse.ok(entries);
}
/** 수수료 상세 — billing branch-fee/{id}/read/{type} */
@GetMapping("/branch-fees/{feeId}")
@Transactional(readOnly = true)
ApiResponse feeDetail(@PathVariable Long feeId,
@RequestParam(defaultValue = "alone") String type,
@AuthenticationPrincipal JwtUser u) {
HomesBranchFee f = em.find(HomesBranchFee.class, feeId);
if (f == null || !Objects.equals(f.getGroupId(), u.getGroupId())) {
return ApiResponse.fail("수수료를 찾을 수 없습니다.");
}
String want = "group".equalsIgnoreCase(type) ? "group" : "alone";
if (!want.equals(f.getFeeType())) {
return ApiResponse.fail("수수료 종류가 일치하지 않습니다.");
}
return ApiResponse.ok(feeDetailMap(f));
}
/** 개별 수수료 등록 */
@PostMapping("/branches/{id}/fees")
@Transactional
ApiResponse createAloneFee(@PathVariable Long id, @RequestBody Map<String, Object> body,
@AuthenticationPrincipal JwtUser u) {
HomesBranch b = findBranch(id, u);
if (b == null) return ApiResponse.fail("영업점을 찾을 수 없습니다.");
String carrier = str(body.get("carrier"));
if (blank(carrier)) return ApiResponse.fail("통신사를 선택하세요.");
LocalDateTime start = parseDateTime(body.get("startDate"));
if (start == null) return ApiResponse.fail("적용일을 입력하세요.");
if (existsAloneFee(u.getGroupId(), b.getOfficeName(), carrier, start, null)) {
return ApiResponse.fail("이미 등록되어 있는 적용일입니다.");
}
HomesBranchFee fee = new HomesBranchFee();
fee.setGroupId(u.getGroupId());
fee.setFeeType("alone");
fee.setBranchName(b.getOfficeName());
if (b.getBranchGroupId() != null) {
HomesBranchGroup g = em.find(HomesBranchGroup.class, b.getBranchGroupId());
if (g != null) fee.setBranchGroupName(g.getName());
}
fee.setCarrier(carrier);
fee.setStartDate(start);
fee.setMemo(str(body.get("memo")));
em.persist(fee);
em.flush();
replaceProducts(fee.getId(), u.getGroupId(), body.get("products"));
return ApiResponse.ok(feeDetailMap(fee));
}
/** 개별 수수료 수정 — 그룹은 거부 */
@PutMapping("/branch-fees/{feeId}")
@Transactional
ApiResponse updateAloneFee(@PathVariable Long feeId, @RequestBody Map<String, Object> body,
@AuthenticationPrincipal JwtUser u) {
HomesBranchFee f = em.find(HomesBranchFee.class, feeId);
if (f == null || !Objects.equals(f.getGroupId(), u.getGroupId())) {
return ApiResponse.fail("수수료를 찾을 수 없습니다.");
}
if (!"alone".equals(f.getFeeType())) {
return ApiResponse.fail("그룹 수수료는 여기서 수정할 수 없습니다.");
}
LocalDateTime start = parseDateTime(body.get("startDate"));
if (start == null) return ApiResponse.fail("적용일을 입력하세요.");
String carrier = blank(str(body.get("carrier"))) ? f.getCarrier() : str(body.get("carrier"));
if (existsAloneFee(u.getGroupId(), f.getBranchName(), carrier, start, feeId)) {
return ApiResponse.fail("이미 등록되어 있는 적용일입니다.");
}
f.setCarrier(carrier);
f.setStartDate(start);
f.setMemo(str(body.get("memo")));
if (body.containsKey("file1")) f.setFile1(str(body.get("file1")));
if (body.containsKey("file2")) f.setFile2(str(body.get("file2")));
if (body.containsKey("file3")) f.setFile3(str(body.get("file3")));
if (body.containsKey("file4")) f.setFile4(str(body.get("file4")));
if (body.containsKey("file5")) f.setFile5(str(body.get("file5")));
replaceProducts(feeId, u.getGroupId(), body.get("products"));
return ApiResponse.ok(feeDetailMap(f));
}
@DeleteMapping("/branch-fees/{feeId}")
@Transactional
ApiResponse deleteAloneFee(@PathVariable Long feeId, @AuthenticationPrincipal JwtUser u) {
HomesBranchFee f = em.find(HomesBranchFee.class, feeId);
if (f == null || !Objects.equals(f.getGroupId(), u.getGroupId())) {
return ApiResponse.fail("수수료를 찾을 수 없습니다.");
}
if (!"alone".equals(f.getFeeType())) {
return ApiResponse.fail("그룹 수수료는 여기서 삭제할 수 없습니다.");
}
em.createQuery("delete from HomesBranchFeeProduct p where p.feeId=:fid and p.groupId=:gid")
.setParameter("fid", feeId)
.setParameter("gid", u.getGroupId())
.executeUpdate();
em.remove(f);
return ApiResponse.ok(null);
}
/** 등록 폼용 기본 상품 템플릿 (시드/기존 수수료 상품명 재사용) */
@GetMapping("/branch-fee-product-templates")
@Transactional(readOnly = true)
ApiResponse productTemplates(@RequestParam(required = false) String carrier,
@AuthenticationPrincipal JwtUser u) {
List<HomesBranchFeeProduct> samples = em.createQuery(
"select p from HomesBranchFeeProduct p where p.groupId=:gid order by p.id asc",
HomesBranchFeeProduct.class)
.setParameter("gid", u.getGroupId())
.setMaxResults(40)
.getResultList();
LinkedHashMap<String, Map<String, Object>> uniq = new LinkedHashMap<>();
for (HomesBranchFeeProduct p : samples) {
String key = p.getProductType() + "|" + p.getProductName();
if (uniq.containsKey(key)) continue;
Map<String, Object> m = new LinkedHashMap<>();
m.put("productType", p.getProductType());
m.put("productName", p.getProductName());
m.put("fee", 0);
uniq.put(key, m);
}
if (uniq.isEmpty()) {
Object[][] defaults = {
{"e01", "기가인터넷 1G"}, {"e01", "안심 인터넷"},
{"e03", "B tv 프리미엄"}, {"e02", "집전화 스마트"}, {"e04", "기타"},
};
for (Object[] d : defaults) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("productType", d[0]);
m.put("productName", d[1]);
m.put("fee", 0);
uniq.put(d[0] + "|" + d[1], m);
}
}
return ApiResponse.ok(new ArrayList<>(uniq.values()));
}
/** 입력처(센터) 후보 — 레벨5 영업점 + 접수 processor distinct */
@GetMapping("/branch-centers")
@Transactional(readOnly = true)
ApiResponse centers(@AuthenticationPrincipal JwtUser u) {
LinkedHashSet<String> names = new LinkedHashSet<>();
em.createQuery(
"select b.officeName from HomesBranch b where b.groupId=:gid and b.level=5 and b.officeName is not null",
String.class)
.setParameter("gid", u.getGroupId())
.getResultList()
.forEach(names::add);
em.createQuery(
"select distinct r.processor from HomesReception r where r.groupId=:gid and r.processor is not null and r.processor<>''",
String.class)
.setParameter("gid", u.getGroupId())
.getResultList()
.forEach(names::add);
List<Map<String, Object>> out = new ArrayList<>();
int i = 1;
for (String n : names) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", i++);
m.put("name", n);
out.add(m);
}
return ApiResponse.ok(out);
}
// ---- helpers ----
private void replaceProducts(Long feeId, String groupId, Object productsRaw) {
em.createQuery("delete from HomesBranchFeeProduct p where p.feeId=:fid and p.groupId=:gid")
.setParameter("fid", feeId)
.setParameter("gid", groupId)
.executeUpdate();
if (!(productsRaw instanceof List<?> list)) return;
for (Object o : list) {
if (!(o instanceof Map<?, ?> m)) continue;
String productType = str(m.get("productType"));
String productName = str(m.get("productName"));
if (blank(productName)) continue;
HomesBranchFeeProduct p = new HomesBranchFeeProduct();
p.setGroupId(groupId);
p.setFeeId(feeId);
p.setProductType(blank(productType) ? "e01" : productType);
p.setProductName(productName);
Object feeObj = m.get("fee");
int fee = feeObj instanceof Number n ? n.intValue() : 0;
try {
if (!(feeObj instanceof Number) && feeObj != null && !String.valueOf(feeObj).isBlank()) {
fee = Integer.parseInt(String.valueOf(feeObj).replace(",", "").replace("원", "").trim());
}
} catch (Exception ignored) { /* keep 0 */ }
p.setFee(fee);
em.persist(p);
}
}
private boolean existsAloneFee(String gid, String branchName, String carrier, LocalDateTime start, Long excludeId) {
String jpql = "select count(f) from HomesBranchFee f where f.groupId=:gid and f.feeType='alone'"
+ " and f.branchName=:name and f.carrier=:c and f.startDate=:sd"
+ (excludeId != null ? " and f.id<>:id" : "");
var q = em.createQuery(jpql, Long.class)
.setParameter("gid", gid)
.setParameter("name", branchName)
.setParameter("c", carrier)
.setParameter("sd", start);
if (excludeId != null) q.setParameter("id", excludeId);
return q.getSingleResult() > 0;
}
private Map<String, Object> feeDetailMap(HomesBranchFee f) {
List<HomesBranchFeeProduct> products = em.createQuery(
"select p from HomesBranchFeeProduct p where p.feeId=:fid and p.groupId=:gid order by p.productType, p.id",
HomesBranchFeeProduct.class)
.setParameter("fid", f.getId())
.setParameter("gid", f.getGroupId())
.getResultList();
Map<String, Object> fee = new LinkedHashMap<>();
fee.put("id", f.getId());
fee.put("type", f.getFeeType());
fee.put("branchName", f.getBranchName());
fee.put("branchGroupName", f.getBranchGroupName());
fee.put("carrier", f.getCarrier());
fee.put("ispName", f.getCarrier());
fee.put("startDate", f.getStartDate() == null ? null : DT.format(f.getStartDate()));
fee.put("memo", f.getMemo());
fee.put("file1", f.getFile1());
fee.put("file2", f.getFile2());
fee.put("file3", f.getFile3());
fee.put("file4", f.getFile4());
fee.put("file5", f.getFile5());
fee.put("source", String.valueOf(f.getId()));
List<Map<String, Object>> productMaps = new ArrayList<>();
for (HomesBranchFeeProduct p : products) {
Map<String, Object> pm = new LinkedHashMap<>();
pm.put("id", p.getId());
pm.put("productType", p.getProductType());
pm.put("productName", p.getProductName());
pm.put("fee", p.getFee() == null ? 0 : p.getFee());
productMaps.add(pm);
}
Map<String, Object> out = new LinkedHashMap<>();
out.put("fee", fee);
out.put("products", productMaps);
return out;
}
private static LocalDateTime parseDateTime(Object raw) {
if (raw == null) return null;
String s = String.valueOf(raw).trim().replace('T', ' ');
if (s.isEmpty()) return null;
try {
if (s.length() == 10) return LocalDateTime.parse(s + " 00:00:00", DT);
if (s.length() >= 19) return LocalDateTime.parse(s.substring(0, 19), DT);
return LocalDateTime.parse(s, DT);
} catch (Exception e) {
try {
return LocalDateTime.parse(s.substring(0, 10) + "T00:00:00");
} catch (Exception e2) {
return null;
}
}
}
private HomesBranch findBranch(Long id, JwtUser u) {
HomesBranch b = em.find(HomesBranch.class, id);
if (b == null || !Objects.equals(b.getGroupId(), u.getGroupId())) return null;
return b;
}
private Map<Long, String> groupNameMap(String groupId) {
return em.createQuery(
"select g from HomesBranchGroup g where g.groupId=:gid", HomesBranchGroup.class)
.setParameter("gid", groupId)
.getResultList()
.stream()
.collect(Collectors.toMap(HomesBranchGroup::getId, HomesBranchGroup::getName, (a, b) -> a));
}
private boolean existsUserid(String gid, String userid, Long excludeId) {
String jpql = "select count(b) from HomesBranch b where b.groupId=:gid and b.userid=:uid"
+ (excludeId != null ? " and b.id<>:id" : "");
var q = em.createQuery(jpql, Long.class).setParameter("gid", gid).setParameter("uid", userid.trim());
if (excludeId != null) q.setParameter("id", excludeId);
return q.getSingleResult() > 0;
}
private boolean existsOfficeName(String gid, String officeName, Long excludeId) {
String jpql = "select count(b) from HomesBranch b where b.groupId=:gid and b.officeName=:n"
+ (excludeId != null ? " and b.id<>:id" : "");
var q = em.createQuery(jpql, Long.class).setParameter("gid", gid).setParameter("n", officeName.trim());
if (excludeId != null) q.setParameter("id", excludeId);
return q.getSingleResult() > 0;
}
private boolean existsEmail(String gid, String email, Long excludeId) {
String jpql = "select count(b) from HomesBranch b where b.groupId=:gid and b.email=:e"
+ (excludeId != null ? " and b.id<>:id" : "");
var q = em.createQuery(jpql, Long.class).setParameter("gid", gid).setParameter("e", email.trim());
if (excludeId != null) q.setParameter("id", excludeId);
return q.getSingleResult() > 0;
}
private Map<String, Object> groupMap(HomesBranchGroup g) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", g.getId());
m.put("name", g.getName());
m.put("seq", g.getSeq());
return m;
}
private Map<String, Object> listMap(HomesBranch b, Map<Long, String> groupNames) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", b.getId());
m.put("name", b.getName());
m.put("officeName", b.getOfficeName());
m.put("userid", b.getUserid());
m.put("tel", b.getTel());
m.put("phone", b.getPhone());
m.put("email", b.getEmail());
m.put("sido", b.getSido());
m.put("level", b.getLevel() == null ? 1 : b.getLevel());
m.put("grant", b.getGrantFlag() == null ? 0 : b.getGrantFlag());
m.put("branchGroupId", b.getBranchGroupId());
m.put("branchGroupName", b.getBranchGroupId() == null ? null : groupNames.get(b.getBranchGroupId()));
m.put("centerName", b.getCenterName());
m.put("hasBusinessLicense", b.isHasBusinessLicense());
m.put("isTax", b.getIsTax() == null ? 1 : b.getIsTax());
m.put("createdAt", b.getCreatedAt() == null ? null
: DT.format(b.getCreatedAt().atZone(KST).toLocalDateTime()));
return m;
}
private Map<String, Object> detailMap(HomesBranch b) {
Map<String, Object> m = listMap(b, groupNameMap(b.getGroupId()));
m.put("fax", b.getFax());
m.put("zipcode", b.getZipcode());
m.put("address", b.getAddress());
m.put("addressDetail", b.getAddressDetail());
m.put("isPersonal", !b.isHasBusinessLicense());
m.put("accountBank", b.getAccountBank());
m.put("accountNumber", b.getAccountNumber());
m.put("accountName", b.getAccountName());
m.put("contactPhone", b.getContactPhone());
m.put("contactNateon", b.getContactNateon());
m.put("acceptPhone", b.getAcceptPhone() == null ? 0 : b.getAcceptPhone());
m.put("acceptNateon", b.getAcceptNateon() == null ? 0 : b.getAcceptNateon());
m.put("memo", b.getMemo());
m.put("adminMemo", b.getAdminMemo());
m.put("licenseNumber", b.getLicenseNumber());
m.put("licenseType", b.getLicenseType());
m.put("licenseItem", b.getLicenseItem());
m.put("notifyPhones", parseNotify(b.getNotifyPhonesJson()));
m.put("permissionOrderDateUpdate", b.isPermissionOrderDateUpdate());
m.put("permissionCalculate", b.isPermissionCalculate());
m.put("permissionConfig", b.isPermissionConfig());
m.put("permissionExcelDownload", b.isPermissionExcelDownload());
m.put("permissionChart", b.isPermissionChart());
return m;
}
private Map<String, Object> feeEntryMap(HomesBranchFee f, String type) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("id", f.getId());
m.put("type", type);
m.put("ispName", f.getCarrier());
m.put("carrier", f.getCarrier());
m.put("category", "공통");
m.put("startDate", f.getStartDate() == null ? null : DT.format(f.getStartDate()));
return m;
}
private static List<String> parseNotify(String json) {
List<String> phones = new ArrayList<>();
if (json != null && json.startsWith("[")) {
String inner = json.substring(1, json.endsWith("]") ? json.length() - 1 : json.length()).trim();
if (!inner.isEmpty()) {
for (String part : inner.split(",")) {
phones.add(part.trim().replace("\"", ""));
}
}
}
while (phones.size() < 6) phones.add("");
return phones.subList(0, 6);
}
private static String toNotifyJson(Object raw) {
List<String> phones = new ArrayList<>();
if (raw instanceof List<?> list) {
for (Object o : list) phones.add(o == null ? "" : String.valueOf(o));
}
while (phones.size() < 6) phones.add("");
StringBuilder sb = new StringBuilder("[");
for (int i = 0; i < 6; i++) {
if (i > 0) sb.append(',');
sb.append('"').append(phones.get(i).replace("\"", "")).append('"');
}
sb.append(']');
return sb.toString();
}
private static String deriveSido(String zipcode, String sido, String address) {
if (!blank(sido)) return sido.trim();
if (!blank(address)) {
String[] parts = address.trim().split("\\s+");
if (parts.length > 0) return parts[0];
}
if (!blank(zipcode) && zipcode.length() >= 2) return zipcode.substring(0, 2);
return "";
}
private static boolean blank(String s) { return s == null || s.isBlank(); }
private static String str(Object o) { return o == null ? "" : String.valueOf(o).trim(); }
private static boolean bool(Object o) {
return Boolean.TRUE.equals(o) || "true".equalsIgnoreCase(String.valueOf(o));
}
private static Long longOrNull(Object o) {
if (o == null || "".equals(o) || "null".equals(String.valueOf(o))) return null;
if (o instanceof Number n) return n.longValue();
try { return Long.parseLong(String.valueOf(o)); } catch (Exception e) { return null; }
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,182 @@
package com.bizcare.agent;
import jakarta.persistence.EntityManager;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.*;
/**
* 접수별 적용 수수료 현황 — billing_imeere BranchGroupFeeController@order / CenterFeeController@order.
*
* 적용가능 수수료가 없으면 fail("적용가능한 수수료가 없습니다.").
*/
@RestController
@RequestMapping("/api/agent/homes/fee")
@RequiredArgsConstructor
class CareFeeController {
private static final ZoneId KST = ZoneId.of("Asia/Seoul");
private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
private final EntityManager em;
@GetMapping("/order/{receptionId}")
@Transactional(readOnly = true)
ApiResponse orderFee(@PathVariable Long receptionId,
@RequestParam(defaultValue = "branch") String kind,
@AuthenticationPrincipal JwtUser u) {
HomesReception r = em.find(HomesReception.class, receptionId);
if (r == null || !Objects.equals(r.getGroupId(), u.getGroupId())) {
return ApiResponse.fail("삭제되었거나 등록되지 않은 접수입니다.");
}
if ("center".equalsIgnoreCase(kind)) {
return centerOrder(r, u);
}
return branchOrder(r, u);
}
private ApiResponse branchOrder(HomesReception r, JwtUser u) {
String branch = blank(r.getBranch()) ? "본지점" : r.getBranch().trim();
String carrier = blank(r.getIspName()) ? (r.getCarrier() == null ? "" : r.getCarrier().trim()) : r.getIspName().trim();
LocalDateTime orderDate = r.getOrderDate() != null ? r.getOrderDate() : LocalDateTime.now(KST);
HomesBranchFee alone = findLatestBranchFee(u.getGroupId(), "alone", branch, carrier, orderDate);
HomesBranchFee group = findLatestBranchFee(u.getGroupId(), "group",
blank(r.getBranchGroup()) ? null : r.getBranchGroup().trim(), carrier, orderDate);
// 그룹 수수료는 branchGroupName 매칭; 없으면 동일 통신사 그룹시트 중 최신
if (group == null) {
group = findLatestBranchFeeByCarrier(u.getGroupId(), "group", carrier, orderDate);
}
if (alone == null && group == null) {
return ApiResponse.fail("적용가능한 수수료가 없습니다.");
}
String type = "group";
HomesBranchFee selected = group;
if (group == null
|| (alone != null && !alone.getStartDate().isBefore(group.getStartDate()))) {
type = "alone";
selected = alone;
}
return ApiResponse.ok(toBranchView(selected, type, branch, carrier));
}
private ApiResponse centerOrder(HomesReception r, JwtUser u) {
String center = blank(r.getProcessor()) ? "본사" : r.getProcessor().trim();
String carrier = blank(r.getIspName()) ? (r.getCarrier() == null ? "" : r.getCarrier().trim()) : r.getIspName().trim();
LocalDateTime orderDate = r.getOrderDate() != null ? r.getOrderDate() : LocalDateTime.now(KST);
HomesCenterFee fee = findLatestCenterFee(u.getGroupId(), center, carrier, orderDate);
if (fee == null) {
return ApiResponse.fail("적용가능한 수수료가 없습니다.");
}
return ApiResponse.ok(toCenterView(fee, center, carrier));
}
private HomesBranchFee findLatestBranchFee(String groupId, String feeType, String branchOrGroup,
String carrier, LocalDateTime orderDate) {
if (blank(branchOrGroup) || blank(carrier)) return null;
String jpql = "alone".equals(feeType)
? "select f from HomesBranchFee f where f.groupId=:g and f.feeType='alone' and f.branchName=:bn and f.carrier=:c and f.startDate<=:d order by f.startDate desc"
: "select f from HomesBranchFee f where f.groupId=:g and f.feeType='group' and f.branchGroupName=:bn and f.carrier=:c and f.startDate<=:d order by f.startDate desc";
List<HomesBranchFee> list = em.createQuery(jpql, HomesBranchFee.class)
.setParameter("g", groupId)
.setParameter("bn", branchOrGroup)
.setParameter("c", carrier)
.setParameter("d", orderDate)
.setMaxResults(1)
.getResultList();
return list.isEmpty() ? null : list.get(0);
}
private HomesBranchFee findLatestBranchFeeByCarrier(String groupId, String feeType, String carrier,
LocalDateTime orderDate) {
if (blank(carrier)) return null;
List<HomesBranchFee> list = em.createQuery(
"select f from HomesBranchFee f where f.groupId=:g and f.feeType=:t and f.carrier=:c and f.startDate<=:d order by f.startDate desc",
HomesBranchFee.class)
.setParameter("g", groupId)
.setParameter("t", feeType)
.setParameter("c", carrier)
.setParameter("d", orderDate)
.setMaxResults(1)
.getResultList();
return list.isEmpty() ? null : list.get(0);
}
private HomesCenterFee findLatestCenterFee(String groupId, String center, String carrier, LocalDateTime orderDate) {
if (blank(center) || blank(carrier)) return null;
List<HomesCenterFee> list = em.createQuery(
"select f from HomesCenterFee f where f.groupId=:g and f.centerName=:cn and f.carrier=:c and f.startDate<=:d order by f.startDate desc",
HomesCenterFee.class)
.setParameter("g", groupId)
.setParameter("cn", center)
.setParameter("c", carrier)
.setParameter("d", orderDate)
.setMaxResults(1)
.getResultList();
return list.isEmpty() ? null : list.get(0);
}
private Map<String, Object> toBranchView(HomesBranchFee fee, String type, String entityName, String carrier) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("kind", "branch");
m.put("type", type);
m.put("feeId", fee.getId());
m.put("entityName", entityName);
m.put("ispName", carrier);
m.put("startDate", fee.getStartDate() == null ? "" : fee.getStartDate().format(DT));
m.put("memo", fee.getMemo());
m.put("products", productsOfBranch(fee.getId()));
return m;
}
private Map<String, Object> toCenterView(HomesCenterFee fee, String entityName, String carrier) {
Map<String, Object> m = new LinkedHashMap<>();
m.put("kind", "center");
m.put("type", "center");
m.put("feeId", fee.getId());
m.put("entityName", entityName);
m.put("ispName", carrier);
m.put("startDate", fee.getStartDate() == null ? "" : fee.getStartDate().format(DT));
m.put("memo", fee.getMemo());
m.put("products", productsOfCenter(fee.getId()));
return m;
}
private List<Map<String, Object>> productsOfBranch(Long feeId) {
return em.createQuery(
"select p from HomesBranchFeeProduct p where p.feeId=:id order by p.id",
HomesBranchFeeProduct.class)
.setParameter("id", feeId)
.getResultList().stream().map(p -> {
Map<String, Object> row = new LinkedHashMap<>();
row.put("productType", p.getProductType());
row.put("productName", p.getProductName());
row.put("fee", p.getFee() == null ? 0 : p.getFee());
return row;
}).toList();
}
private List<Map<String, Object>> productsOfCenter(Long feeId) {
return em.createQuery(
"select p from HomesCenterFeeProduct p where p.feeId=:id order by p.id",
HomesCenterFeeProduct.class)
.setParameter("id", feeId)
.getResultList().stream().map(p -> {
Map<String, Object> row = new LinkedHashMap<>();
row.put("productType", p.getProductType());
row.put("productName", p.getProductName());
row.put("fee", p.getFee() == null ? 0 : p.getFee());
return row;
}).toList();
}
private static boolean blank(String s) { return s == null || s.isBlank(); }
}
@@ -2845,6 +2845,7 @@ class CareHomesController {
m.put("상품관리", "product");
m.put("계약관리", "contract");
m.put("접수관리", "reception");
m.put("영업점관리", "branch");
m.put("정산관리", "invoice");
m.put("일정관리", "pending");
m.put("예약관리", "booking");
@@ -2865,7 +2866,7 @@ class CareHomesController {
Map<String, Boolean> exposure = parseMenuFlagsMap(menuFlagsRaw);
if (isOwner) {
for (String id : List.of("home", "product", "contract", "reception", "invoice", "pending", "booking",
for (String id : List.of("home", "product", "contract", "reception", "branch", "invoice", "pending", "booking",
"customer", "abooks", "report", "setting", "cs", "bbs", "sitemap")) {
if (Boolean.FALSE.equals(exposure.get(id))) continue;
allowed.add(id);
@@ -2923,7 +2924,7 @@ class CareHomesController {
private Map<String, Boolean> parseMenuFlagsMap(String raw) {
Map<String, Boolean> flags = new LinkedHashMap<>();
for (String id : List.of("home", "product", "contract", "reception", "invoice", "pending", "booking",
for (String id : List.of("home", "product", "contract", "reception", "branch", "invoice", "pending", "booking",
"customer", "abooks", "report", "cs", "bbs", "setting", "sitemap")) {
flags.put(id, true);
}
@@ -3069,7 +3070,7 @@ class CareHomesController {
private static final Map<String, List<String>> LEVEL_ROWS;
static {
Map<String, List<String>> rows = new LinkedHashMap<>();
rows.put("menu", List.of("상품관리", "계약관리", "접수관리", "정산관리", "일정관리", "예약관리", "고객관리", "장부관리", "통계관리", "환경설정"));
rows.put("menu", List.of("상품관리", "계약관리", "접수관리", "영업점관리", "정산관리", "일정관리", "예약관리", "고객관리", "장부관리", "통계관리", "환경설정"));
rows.put("product", List.of("목록조회", "상품등록", "상품수정", "상품삭제", "정책관리", "엑셀다운로드"));
rows.put("contract", List.of("목록조회", "계약등록", "계약수정", "상태변경", "엑셀다운로드"));
rows.put("reception", List.of("목록조회", "접수등록", "접수수정", "진행변경", "엑셀다운로드"));
@@ -3120,7 +3121,7 @@ class CareHomesController {
for (String menu : menus) {
boolean allow = switch (role) {
case "총괄", "팀장", "개발자" -> true;
case "사원" -> List.of("계약관리", "접수관리", "정산관리", "일정관리", "예약관리", "고객관리", "장부관리").contains(menu);
case "사원" -> List.of("계약관리", "접수관리", "영업점관리", "정산관리", "일정관리", "예약관리", "고객관리", "장부관리").contains(menu);
case "영업", "딜러" -> List.of("계약관리", "접수관리", "정산관리", "일정관리", "예약관리").contains(menu);
default -> false;
};
@@ -29,10 +29,9 @@ class CareReceptionController {
private final EntityManager em;
private final FileStorageService fileStorage;
static final List<String> PROGRESS_STATUSES = List.of(
"1.접수완료", "2.고객통화", "3.서류검토", "4.개통완료", "5.사은품지급", "6.정산완료", "9.취소");
static final List<String> SETTLE_STATUSES = List.of("정산대기", "정산완료", "회수대기", "회수완료");
static final List<String> GIFT_STATUSES = List.of("미지급", "지급대기", "지급완료", "반송");
static final List<String> PROGRESS_STATUSES = ReceptionStatuses.progressCodes();
static final List<String> SETTLE_STATUSES = ReceptionStatuses.centerCodes();
static final List<String> GIFT_STATUSES = ReceptionStatuses.giftCodes();
static final List<String> CARRIERS = List.of("KT", "SKB", "LGU+", "SKT", "기타");
static final List<String> REGIONS = List.of(
"서울", "경기", "인천", "강원", "충북", "충남", "대전", "전북", "전남", "광주",
@@ -47,7 +46,9 @@ class CareReceptionController {
Map<String, Long> counts = new LinkedHashMap<>();
counts.put("ALL", (long) filtered.size());
for (String st : PROGRESS_STATUSES) {
counts.put(st, filtered.stream().filter(r -> st.equals(r.getProgressStatus())).count());
counts.put(st, filtered.stream()
.filter(r -> st.equals(ReceptionStatuses.normalizeProgress(r.getProgressStatus())))
.count());
}
int fromIdx = Math.max(0, page) * Math.max(1, size);
int limit = Math.min(Math.max(size, 1), 200);
@@ -60,6 +61,10 @@ class CareReceptionController {
data.put("progressStatuses", PROGRESS_STATUSES);
data.put("settleStatuses", SETTLE_STATUSES);
data.put("giftStatuses", GIFT_STATUSES);
data.put("progressStatusLabels", ReceptionStatuses.PROGRESS);
data.put("giftStatusLabels", ReceptionStatuses.GIFT);
data.put("settleStatusLabels", ReceptionStatuses.CENTER);
data.put("giftPlaceLabels", ReceptionStatuses.GIFT_PLACE);
data.put("carriers", distinct(filtered, HomesReception::getCarrier, CARRIERS));
data.put("branches", distinct(filtered, HomesReception::getBranch, List.of()));
data.put("counselors", distinct(filtered, HomesReception::getCounselor, List.of()));
@@ -170,32 +175,61 @@ class CareReceptionController {
boolean adminOnly = Boolean.TRUE.equals(body.get("adminOnly")) || "true".equalsIgnoreCase(String.valueOf(body.get("adminOnly")));
boolean statusChanged = false;
if (!blank(newStatus) && !Objects.equals(newStatus, r.getProgressStatus())) {
r.setProgressStatus(newStatus);
statusChanged = true;
if ("4.개통완료".equals(newStatus) && blank(r.getGiftStatus())) {
r.setGiftStatus("지급대기");
}
if ("4.개통완료".equals(newStatus) && r.getOpenDate() == null) {
r.setOpenDate(LocalDate.now(ZoneId.of("Asia/Seoul")));
String prevProgress = ReceptionStatuses.normalizeProgress(r.getProgressStatus());
if (!blank(newStatus)) {
newStatus = ReceptionStatuses.normalizeProgress(newStatus);
if (!Objects.equals(newStatus, prevProgress)) {
Set<String> allowed = ReceptionStatuses.orderStatusTransitions(prevProgress);
if (("f23".equals(prevProgress) || "f24".equals(prevProgress) || "f25".equals(prevProgress))
&& !allowed.isEmpty() && !allowed.contains(newStatus)) {
return ApiResponse.fail("정산 관련 상태는 정산현황 메뉴에서 변경하세요. (현재: "
+ ReceptionStatuses.progressLabel(prevProgress) + ")");
}
r.setProgressStatus(newStatus);
statusChanged = true;
applyOpenSideEffects(r, newStatus, u);
}
}
String giftStatus = str(body.get("giftStatus"));
boolean giftChanged = false;
if (!blank(giftStatus) && !Objects.equals(giftStatus, r.getGiftStatus())) {
r.setGiftStatus(giftStatus);
giftChanged = true;
appendGiftLog(r, giftStatus, str(body.get("giftProgressLog")), u);
if ("지급완료".equals(giftStatus) && "4.개통완료".equals(r.getProgressStatus())) {
r.setProgressStatus("6.정산완료");
r.setSettleStatus("정산완료");
statusChanged = true;
if (body.containsKey("giftStatus")) {
giftStatus = ReceptionStatuses.normalizeGift(giftStatus);
String prevGift = ReceptionStatuses.normalizeGift(r.getGiftStatus());
if (!Objects.equals(giftStatus, prevGift)) {
r.setGiftStatus(blank(giftStatus) ? null : giftStatus);
if ("g01".equals(giftStatus) && r.getGiftRequested() == null) {
r.setGiftRequested(LocalDate.now(ZoneId.of("Asia/Seoul")));
}
if ("g07".equals(giftStatus)) {
if (r.getGiftSended() == null) {
LocalDate today = LocalDate.now(ZoneId.of("Asia/Seoul"));
r.setGiftSended(today);
r.setGiftDate(today);
}
String cur = ReceptionStatuses.normalizeProgress(r.getProgressStatus());
if ("f14".equals(cur)) {
r.setProgressStatus("f23");
r.setSettleStatus("h01");
statusChanged = true;
appendLog(r, "f23", "사은품 지급완료 후 정산대기 자동변경", u, false);
}
}
appendGiftLog(r, blank(giftStatus) ? prevGift : giftStatus, str(body.get("giftProgressLog")), u);
} else if (!blank(str(body.get("giftProgressLog")))) {
appendGiftLog(r, r.getGiftStatus(), str(body.get("giftProgressLog")), u);
}
} else if (!blank(str(body.get("giftProgressLog")))) {
appendGiftLog(r, r.getGiftStatus(), str(body.get("giftProgressLog")), u);
}
String centerStatus = str(body.containsKey("centerStatus") ? body.get("centerStatus") : body.get("settleStatus"));
if (!blank(centerStatus)) {
centerStatus = ReceptionStatuses.normalizeCenter(centerStatus);
if (!Objects.equals(centerStatus, ReceptionStatuses.normalizeCenter(r.getSettleStatus()))) {
r.setSettleStatus(centerStatus);
}
}
if (statusChanged || !blank(progressMemo)) {
appendLog(r, blank(newStatus) ? r.getProgressStatus() : newStatus, progressMemo, u, adminOnly);
}
@@ -210,6 +244,30 @@ class CareReceptionController {
return ApiResponse.ok(toProgressDetail(r, u));
}
/** f14 개통완료 시 사은품 g01 + 입력처 h01 + 개통일 */
private void applyOpenSideEffects(HomesReception r, String newStatus, JwtUser u) {
if ("f22".equals(newStatus) && r.getCancelDate() == null) {
r.setCancelDate(LocalDate.now(ZoneId.of("Asia/Seoul")));
}
if (ReceptionStatuses.isPreCalc(newStatus)) {
r.setReceptionCostId(null);
}
if (!"f14".equals(newStatus)) return;
if (r.getOpenDate() == null) {
r.setOpenDate(LocalDate.now(ZoneId.of("Asia/Seoul")));
}
String gift = ReceptionStatuses.normalizeGift(r.getGiftStatus());
if (blank(gift)) {
r.setGiftStatus("g01");
if (r.getGiftRequested() == null) {
r.setGiftRequested(LocalDate.now(ZoneId.of("Asia/Seoul")));
}
appendGiftLog(r, "g01", "개통완료 후 자동변경", u);
}
String center = ReceptionStatuses.normalizeCenter(r.getSettleStatus());
r.setSettleStatus(blank(center) ? "h01" : center);
}
@DeleteMapping("/receptions/progress-log/{logId}")
@Transactional
ApiResponse deleteProgressLog(@PathVariable Long logId, @AuthenticationPrincipal JwtUser u) {
@@ -235,9 +293,12 @@ class CareReceptionController {
r.setGroupId(u.getGroupId());
apply(r, body);
if (r.getOrderDate() == null) r.setOrderDate(LocalDateTime.now(ZoneId.of("Asia/Seoul")));
if (blank(r.getProgressStatus())) r.setProgressStatus("1.접수완료");
if (blank(r.getSettleStatus())) r.setSettleStatus("정산대기");
if (blank(r.getGiftStatus())) r.setGiftStatus("미지급");
if (blank(r.getProgressStatus())) r.setProgressStatus("f01");
else r.setProgressStatus(ReceptionStatuses.normalizeProgress(r.getProgressStatus()));
if (blank(r.getSettleStatus())) r.setSettleStatus("h01");
else r.setSettleStatus(ReceptionStatuses.normalizeCenter(r.getSettleStatus()));
if (blank(r.getGiftStatus()) || "미지급".equals(r.getGiftStatus())) r.setGiftStatus(null);
else r.setGiftStatus(ReceptionStatuses.normalizeGift(r.getGiftStatus()));
if (blank(r.getOrderNumber())) r.setOrderNumber(genOrderNumber());
em.persist(r);
em.flush();
@@ -313,12 +374,13 @@ class CareReceptionController {
int n = 0;
for (HomesReception r : rows) {
r.setProgressStatus(status);
String normalized = ReceptionStatuses.normalizeProgress(status);
r.setProgressStatus(normalized);
if (!blank(processor)) r.setProcessor(processor);
if (opened != null) r.setOpenDate(opened);
if (canceled != null) r.setCancelDate(canceled);
if ("4.개통완료".equals(status) && blank(r.getGiftStatus())) r.setGiftStatus("지급대기");
appendLog(r, status, memo, u, adminOnly);
applyOpenSideEffects(r, normalized, u);
appendLog(r, normalized, memo, u, adminOnly);
n++;
}
writeLog(u, "RECEPTION", "접수-일괄진행", "접수 " + n + "건 진행상황을 [" + status + "]로 변경하였습니다.");
@@ -359,7 +421,8 @@ class CareReceptionController {
int n = 0;
for (HomesReception r : rows) {
r.setGiftStatus(status);
String normalized = ReceptionStatuses.normalizeGift(status);
r.setGiftStatus(blank(normalized) ? null : normalized);
if (!blank(place)) r.setGiftPlace(place);
if (!blank(deliveryCompany)) r.setDeliveryCompany(deliveryCompany);
if (!blank(trackingNumber)) r.setTrackingNumber(trackingNumber);
@@ -368,7 +431,22 @@ class CareReceptionController {
r.setGiftSended(sended);
r.setGiftDate(sended);
}
appendGiftLog(r, status, giftMemo, u);
if ("g01".equals(normalized) && r.getGiftRequested() == null) {
r.setGiftRequested(LocalDate.now(ZoneId.of("Asia/Seoul")));
}
if ("g07".equals(normalized)) {
if (r.getGiftSended() == null) {
LocalDate today = LocalDate.now(ZoneId.of("Asia/Seoul"));
r.setGiftSended(today);
r.setGiftDate(today);
}
if ("f14".equals(ReceptionStatuses.normalizeProgress(r.getProgressStatus()))) {
r.setProgressStatus("f23");
r.setSettleStatus("h01");
appendLog(r, "f23", "사은품 일괄지급완료 후 정산대기", u, false);
}
}
appendGiftLog(r, blank(normalized) ? status : normalized, giftMemo, u);
n++;
}
writeLog(u, "RECEPTION", "사은품-일괄", "접수 " + n + "건 사은품진행을 [" + status + "]로 변경하였습니다.");
@@ -385,9 +463,9 @@ class CareReceptionController {
for (Long id : ids) {
HomesReception r = findOwned(id, u);
if (r == null) return ApiResponse.fail("접수 리스트가 올바르지 않습니다.");
// 입력처 정산이 비어있거나 미지정인 건만 대상
if (!blank(r.getSettleStatus()) && !"정산대기".equals(r.getSettleStatus())
&& !"미지정".equals(r.getSettleStatus())) {
// 입력처 정산이 비어있거나 정산대기(h01)인 건만 대상
String st = ReceptionStatuses.normalizeCenter(r.getSettleStatus());
if (!blank(st) && !"h01".equals(st)) {
return ApiResponse.fail("접수 리스트가 올바르지 않습니다.");
}
rows.add(r);
@@ -395,7 +473,7 @@ class CareReceptionController {
int n = 0;
for (HomesReception r : rows) {
r.setSettleStatus("정산대기");
r.setSettleStatus("h01");
appendLog(r, r.getProgressStatus(), "[일괄변경] 입력처 정산대기", u, false);
n++;
}
@@ -540,8 +618,10 @@ class CareReceptionController {
r.setGiftCategory(giftCategory);
r.setGiftPlace(giftPlaceCode);
r.setGiftDate(parseDate(str(gift.get("giftDate"))));
r.setGiftStatus(blank(giftType) || "a01".equals(giftType) ? "미지급" : "지급대기");
r.setGiftCashAmount(toDecimal(gift.get("cashAmount")));
r.setGiftStatus(blank(giftType) || "a01".equals(giftType) ? null : "g01");
if ("g01".equals(r.getGiftStatus())) {
r.setGiftRequested(LocalDate.now(ZoneId.of("Asia/Seoul")));
} r.setGiftCashAmount(toDecimal(gift.get("cashAmount")));
r.setGiftGoods(str(gift.get("goods")));
r.setGiftGoodsPrice(toDecimal(gift.get("goodsPrice")));
r.setGiftBank(str(gift.get("accountBank")));
@@ -559,8 +639,8 @@ class CareReceptionController {
r.setPaymentCardYear(str(payment.get("cardYear")));
r.setPaymentCardMonth(str(payment.get("cardMonth")));
r.setPaymentCardNumber(str(payment.get("cardNumber")));
r.setProgressStatus("1.접수완료");
r.setSettleStatus("정산대기");
r.setProgressStatus("f01");
r.setSettleStatus("h01");
r.setOrderNumber(genOrderNumber());
r.setProcessor(r.getCounselor());
r.setDetailProgress("신규신청");
@@ -766,9 +846,21 @@ class CareReceptionController {
LocalDate gd = r.getGiftSended() != null ? r.getGiftSended() : r.getGiftDate();
if (gd == null || !gd.equals(giftSended)) return false;
}
if (progressF != null && !progressF.equals(r.getProgressStatus())) return false;
if (settle != null && !settle.equals(r.getSettleStatus())) return false;
if (giftStatus != null && !giftStatus.equals(r.getGiftStatus())) return false;
if (progressF != null) {
String rp = ReceptionStatuses.normalizeProgress(r.getProgressStatus());
String fp = ReceptionStatuses.normalizeProgress(progressF);
if (!fp.equals(rp) && !progressF.equals(r.getProgressStatus())) return false;
}
if (settle != null) {
String rs = ReceptionStatuses.normalizeCenter(r.getSettleStatus());
String fs = ReceptionStatuses.normalizeCenter(settle);
if (!fs.equals(rs) && !settle.equals(r.getSettleStatus())) return false;
}
if (giftStatus != null) {
String rg = ReceptionStatuses.normalizeGift(r.getGiftStatus());
String fg = ReceptionStatuses.normalizeGift(giftStatus);
if (!fg.equals(rg) && !giftStatus.equals(r.getGiftStatus())) return false;
}
if (carrier != null && !carrier.equals(r.getCarrier()) && !carrier.equals(r.getIspName())) return false;
if (ispName != null) {
String hay = (nullToEmpty(r.getIspName()) + " " + nullToEmpty(r.getCarrier())).toLowerCase();
@@ -829,7 +921,7 @@ class CareReceptionController {
if (d.containsKey("customerName")) r.setCustomerName(str(d.get("customerName")));
if (d.containsKey("phone")) r.setPhone(str(d.get("phone")));
if (d.containsKey("region")) r.setRegion(str(d.get("region")));
if (d.containsKey("progressStatus")) r.setProgressStatus(str(d.get("progressStatus")));
if (d.containsKey("progressStatus")) r.setProgressStatus(ReceptionStatuses.normalizeProgress(str(d.get("progressStatus"))));
if (d.containsKey("orderNumber")) r.setOrderNumber(str(d.get("orderNumber")));
if (d.containsKey("ispId")) r.setIspId(toLong(d.get("ispId")));
if (d.containsKey("ispName")) r.setIspName(str(d.get("ispName")));
@@ -838,11 +930,13 @@ class CareReceptionController {
if (d.containsKey("detailProgress")) r.setDetailProgress(str(d.get("detailProgress")));
if (d.containsKey("paymentType")) r.setPaymentType(str(d.get("paymentType")));
if (d.containsKey("amount")) r.setAmount(toDecimal(d.get("amount")));
if (d.containsKey("settleStatus")) r.setSettleStatus(str(d.get("settleStatus")));
if (d.containsKey("settleStatus")) r.setSettleStatus(ReceptionStatuses.normalizeCenter(str(d.get("settleStatus"))));
if (d.containsKey("openDate")) r.setOpenDate(parseDate(str(d.get("openDate"))));
if (d.containsKey("giftDate")) r.setGiftDate(parseDate(str(d.get("giftDate"))));
if (d.containsKey("giftStatus")) r.setGiftStatus(str(d.get("giftStatus")));
if (d.containsKey("giftPlace")) r.setGiftPlace(str(d.get("giftPlace")));
if (d.containsKey("giftStatus")) {
String g = ReceptionStatuses.normalizeGift(str(d.get("giftStatus")));
r.setGiftStatus(blank(g) ? null : g);
} if (d.containsKey("giftPlace")) r.setGiftPlace(str(d.get("giftPlace")));
if (d.containsKey("agreementName")) r.setAgreementName(str(d.get("agreementName")));
if (d.containsKey("contractNumber")) r.setContractNumber(str(d.get("contractNumber")));
if (d.containsKey("identifyNumber")) r.setIdentifyNumber(str(d.get("identifyNumber")));
@@ -953,12 +1047,14 @@ class CareReceptionController {
data.put("progressStatuses", PROGRESS_STATUSES);
data.put("giftStatuses", GIFT_STATUSES);
data.put("settleStatuses", SETTLE_STATUSES);
Map<String, String> statusOptions = new LinkedHashMap<>();
for (String s : PROGRESS_STATUSES) statusOptions.put(s, s);
data.put("statusOptions", statusOptions);
Map<String, String> giftStatusOptions = new LinkedHashMap<>();
for (String s : GIFT_STATUSES) giftStatusOptions.put(s, s);
data.put("giftStatusOptions", giftStatusOptions);
data.put("progressStatusLabels", ReceptionStatuses.PROGRESS);
data.put("giftStatusLabels", ReceptionStatuses.GIFT);
data.put("settleStatusLabels", ReceptionStatuses.CENTER);
data.put("statusOptions", new LinkedHashMap<>(ReceptionStatuses.PROGRESS));
data.put("giftStatusOptions", new LinkedHashMap<>(ReceptionStatuses.GIFT));
data.put("centerStatusOptions", new LinkedHashMap<>(ReceptionStatuses.CENTER));
data.put("allowedProgressStatuses", new ArrayList<>(
ReceptionStatuses.orderStatusTransitions(ReceptionStatuses.normalizeProgress(r.getProgressStatus()))));
// flat alias for list edit form compatibility
data.putAll(reception);
return data;
@@ -1003,6 +1099,8 @@ class CareReceptionController {
m.put("addressDetail", r.getAddressDetail());
m.put("region", r.getRegion());
m.put("progressStatus", r.getProgressStatus());
m.put("progressStatusLabel", ReceptionStatuses.progressLabel(ReceptionStatuses.normalizeProgress(r.getProgressStatus())));
m.put("status", ReceptionStatuses.progressLabel(ReceptionStatuses.normalizeProgress(r.getProgressStatus())));
m.put("orderNumber", r.getOrderNumber());
m.put("ispId", r.getIspId());
m.put("ispName", r.getIspName());
@@ -1038,8 +1136,11 @@ class CareReceptionController {
m.put("sido", r.getRegion());
m.put("opened", r.getOpenDate() == null ? null : r.getOpenDate().toString());
m.put("canceled", r.getCancelDate() == null ? null : r.getCancelDate().toString());
m.put("status", r.getProgressStatus());
m.put("giftType", r.getGiftCategory());
m.put("giftStatusLabel", ReceptionStatuses.giftLabel(ReceptionStatuses.normalizeGift(r.getGiftStatus())));
m.put("settleStatusLabel", ReceptionStatuses.centerLabel(ReceptionStatuses.normalizeCenter(r.getSettleStatus())));
m.put("centerStatus", r.getSettleStatus());
m.put("centerStatusLabel", ReceptionStatuses.centerLabel(ReceptionStatuses.normalizeCenter(r.getSettleStatus())));
m.put("giftSended", r.getGiftSended() == null
? (r.getGiftDate() == null ? null : r.getGiftDate().toString())
: r.getGiftSended().toString());
@@ -1050,7 +1151,8 @@ class CareReceptionController {
if (r.getGiftCashAmount() != null) giftAmt = giftAmt.add(r.getGiftCashAmount());
if (r.getGiftGoodsPrice() != null) giftAmt = giftAmt.add(r.getGiftGoodsPrice());
if (giftAmt.signum() == 0 && r.getAmount() != null
&& r.getGiftStatus() != null && !"미지급".equals(r.getGiftStatus()) && !"없음".equals(r.getGiftCategory())) {
&& r.getGiftStatus() != null && !r.getGiftStatus().isBlank()
&& !"미지급".equals(r.getGiftStatus()) && !"없음".equals(r.getGiftCategory())) {
giftAmt = r.getAmount();
}
m.put("giftAmount", giftAmt.signum() == 0 ? null : giftAmt);
@@ -71,8 +71,28 @@ class DataInitializer implements CommandLineRunner {
seedScheduleSampleIfEmpty();
seedPostitsIfEmpty();
seedStockInfoSampleIfEmpty();
migrateReceptionStatusesToCodes();
seedCalculateSamplesIfEmpty();
seedFeeSheetsIfEmpty();
seedBranchesIfEmpty();
}
/** 구 한글 진행/사은품/입력처 상태를 billing 코드(f/g/h)로 변환 */
private void migrateReceptionStatusesToCodes() {
List<HomesReception> rows = em.createQuery("select r from HomesReception r", HomesReception.class).getResultList();
for (HomesReception r : rows) {
String p = ReceptionStatuses.normalizeProgress(r.getProgressStatus());
if (p != null && !p.equals(r.getProgressStatus())) r.setProgressStatus(p.isBlank() ? null : p);
String g = ReceptionStatuses.normalizeGift(r.getGiftStatus());
if (g != null && !g.equals(nullToEmpty(r.getGiftStatus()))) r.setGiftStatus(g.isBlank() ? null : g);
String c = ReceptionStatuses.normalizeCenter(r.getSettleStatus());
if (c != null && !c.equals(nullToEmpty(r.getSettleStatus()))) r.setSettleStatus(c.isBlank() ? null : c);
}
em.flush();
}
private static String nullToEmpty(String s) { return s == null ? "" : s; }
private void seedStockInfoSampleIfEmpty() {
Long exists = em.createQuery("select count(s) from Stock s where s.groupId='demo' and s.serial='0018987'", Long.class).getSingleResult();
if (exists != null && exists > 0) return;
@@ -2325,16 +2345,16 @@ class DataInitializer implements CommandLineRunner {
em.flush();
if (em.createQuery("select count(r) from HomesReception r where r.groupId='demo'", Long.class).getSingleResult() == 0) {
Object[][] rows = {
{LocalDateTime.of(2026, 7, 10, 9, 12), "김상담", "강남점", "KT", "상품권", "인터넷", "기가인터넷 1G", "홍*동", "010-1111-2222", "서울", "1.접수완료", "SAMPLE-00001", "서울입고", "박처리", "접수", "현금", "100000", "정산대기", null, null, "미지급", "본사", "36개월", "02-1234-5678"},
{LocalDateTime.of(2026, 7, 10, 10, 30), "이상담", "서초점", "SKB", "가전", "TV", "B tv 프리미엄", "김*수", "010-2222-3333", "경기", "2.고객통화", "SAMPLE-00002", "", "최처리", "통화완료", "계좌", "80000", "정산대기", null, null, "지급대기", "영업점", "24개월", ""},
{LocalDateTime.of(2026, 7, 9, 14, 5), "김상담", "강남점", "LGU+", "상품권", "인터넷", "U+ 인터넷", "박*지", "010-3333-4444", "인천", "4.개통완료", "SAMPLE-00003", "서울입고", "박처리", "개통", "현금", "120000", "정산대기", LocalDate.of(2026, 7, 9), null, "미지급", "본사", "36개월", "032-555-1212"},
{LocalDateTime.of(2026, 7, 8, 11, 20), "정상담", "송파점", "KT", "캐시백", "정수기", "청호 450", "최*아", "010-4444-5555", "서울", "4.개통완료", "SAMPLE-00004", "물류완료", "이처리", "설치완료", "카드", "150000", "정산완료", LocalDate.of(2026, 7, 8), LocalDate.of(2026, 7, 10), "지급완료", "본사", "60개월", ""},
{LocalDateTime.of(2026, 7, 8, 16, 40), "이상담", "서초점", "SKT", "상품권", "전화", "집전화 스마트", "정*민", "010-5555-6666", "부산", "3.서류검토", "SAMPLE-00005", "", "최처리", "서류요청", "현금", "15000", "정산대기", null, null, "미지급", "본사", "24개월", ""},
{LocalDateTime.of(2026, 7, 7, 9, 50), "김상담", "강남점", "KT", "가전", "공기청정기", "퓨리케어 360", "이*수", "010-6666-7777", "서울", "5.사은품지급", "SAMPLE-00006", "서울입고", "박처리", "사은품발송", "계좌", "90000", "정산대기", LocalDate.of(2026, 7, 6), LocalDate.of(2026, 7, 7), "지급완료", "본사", "36개월", ""},
{LocalDateTime.of(2026, 7, 6, 13, 15), "정상담", "송파점", "SKB", "상품권", "인터넷", "B tv 인터넷", "윤*희", "010-7777-8888", "경기", "6.정산완료", "SAMPLE-00007", "완료", "이처리", "정산", "현금", "75000", "정산완료", LocalDate.of(2026, 7, 5), LocalDate.of(2026, 7, 6), "지급완료", "영업점", "36개월", ""},
{LocalDateTime.of(2026, 7, 5, 15, 0), "이상담", "서초점", "LGU+", "캐시백", "와이파이", "와이파이 메시", "한*진", "010-8888-9999", "대전", "9.취소", "SAMPLE-00008", "", "최처리", "고객취소", "", "0", "회수대기", null, null, "반송", "", "12개월", ""},
{LocalDateTime.of(2026, 7, 11, 8, 45), "김상담", "강남점", "KT", "상품권", "인터넷", "안심 인터넷 베이직", "오*라", "010-1212-3434", "서울", "1.접수완료", "SAMPLE-00009", "", "박처리", "접수", "현금", "60000", "정산대기", null, null, "미지급", "본사", "36개월", ""},
{LocalDateTime.of(2026, 7, 11, 11, 10), "정상담", "송파점", "SKB", "가전", "비데", "스스로케어 비데", "서*호", "010-5656-7878", "경남", "2.고객통화", "SAMPLE-00010", "물류대기", "이처리", "통화예약", "계좌", "60000", "정산대기", null, null, "지급대기", "본사", "36개월", ""},
{LocalDateTime.of(2026, 7, 10, 9, 12), "김상담", "강남점", "KT", "상품권", "인터넷", "기가인터넷 1G", "홍*동", "010-1111-2222", "서울", "f01", "SAMPLE-00001", "서울입고", "박처리", "접수", "현금", "100000", "h01", null, null, "", "b01", "36개월", "02-1234-5678"},
{LocalDateTime.of(2026, 7, 10, 10, 30), "이상담", "서초점", "SKB", "가전", "TV", "B tv 프리미엄", "김*수", "010-2222-3333", "경기", "f03", "SAMPLE-00002", "", "최처리", "통화완료", "계좌", "80000", "h01", null, null, "g01", "b02", "24개월", ""},
{LocalDateTime.of(2026, 7, 9, 14, 5), "김상담", "강남점", "LGU+", "상품권", "인터넷", "U+ 인터넷", "박*지", "010-3333-4444", "인천", "f14", "SAMPLE-00003", "서울입고", "박처리", "개통", "현금", "120000", "h01", LocalDate.of(2026, 7, 9), null, "g01", "b01", "36개월", "032-555-1212"},
{LocalDateTime.of(2026, 7, 8, 11, 20), "정상담", "송파점", "KT", "캐시백", "정수기", "청호 450", "최*아", "010-4444-5555", "서울", "f23", "SAMPLE-00004", "물류완료", "이처리", "설치완료", "카드", "150000", "h01", LocalDate.of(2026, 7, 8), LocalDate.of(2026, 7, 10), "g07", "b01", "60개월", ""},
{LocalDateTime.of(2026, 7, 8, 16, 40), "이상담", "서초점", "SKT", "상품권", "전화", "집전화 스마트", "정*민", "010-5555-6666", "부산", "f05", "SAMPLE-00005", "", "최처리", "서류요청", "현금", "15000", "h01", null, null, "", "b01", "24개월", ""},
{LocalDateTime.of(2026, 7, 7, 9, 50), "김상담", "강남점", "KT", "가전", "공기청정기", "퓨리케어 360", "이*수", "010-6666-7777", "서울", "f14", "SAMPLE-00006", "서울입고", "박처리", "사은품발송", "계좌", "90000", "h01", LocalDate.of(2026, 7, 6), LocalDate.of(2026, 7, 7), "g07", "b01", "36개월", ""},
{LocalDateTime.of(2026, 7, 6, 13, 15), "정상담", "송파점", "SKB", "상품권", "인터넷", "B tv 인터넷", "윤*희", "010-7777-8888", "경기", "f24", "SAMPLE-00007", "완료", "이처리", "정산", "현금", "75000", "h02", LocalDate.of(2026, 7, 5), LocalDate.of(2026, 7, 6), "g07", "b02", "36개월", ""},
{LocalDateTime.of(2026, 7, 5, 15, 0), "이상담", "서초점", "LGU+", "캐시백", "와이파이", "와이파이 메시", "한*진", "010-8888-9999", "대전", "f11", "SAMPLE-00008", "", "최처리", "고객취소", "", "0", "h05", null, null, "g03", "", "12개월", ""},
{LocalDateTime.of(2026, 7, 11, 8, 45), "김상담", "강남점", "KT", "상품권", "인터넷", "안심 인터넷 베이직", "오*라", "010-1212-3434", "서울", "f01", "SAMPLE-00009", "", "박처리", "접수", "현금", "60000", "h01", null, null, "", "b01", "36개월", ""},
{LocalDateTime.of(2026, 7, 11, 11, 10), "정상담", "송파점", "SKB", "가전", "비데", "스스로케어 비데", "서*호", "010-5656-7878", "경남", "f03", "SAMPLE-00010", "물류대기", "이처리", "통화예약", "계좌", "60000", "h01", null, null, "g01", "b01", "36개월", ""},
};
for (Object[] s : rows) {
HomesReception r = new HomesReception();
@@ -2359,7 +2379,7 @@ class DataInitializer implements CommandLineRunner {
r.setSettleStatus(String.valueOf(s[17]));
r.setOpenDate(s[18] == null ? null : (LocalDate) s[18]);
r.setGiftDate(s[19] == null ? null : (LocalDate) s[19]);
r.setGiftStatus(String.valueOf(s[20]));
r.setGiftStatus(String.valueOf(s[20]).isBlank() ? null : String.valueOf(s[20]));
r.setGiftPlace(String.valueOf(s[21]));
r.setAgreementName(String.valueOf(s[22]));
r.setContractNumber(String.valueOf(s[23]));
@@ -2841,7 +2861,8 @@ class DataInitializer implements CommandLineRunner {
if (r.getTel() == null || r.getTel().isBlank()) r.setTel("02-100" + i);
if (r.getGiftSended() == null && r.getGiftDate() != null) r.setGiftSended(r.getGiftDate());
if (r.getGiftCashAmount() == null && r.getAmount() != null
&& r.getGiftStatus() != null && !"미지급".equals(r.getGiftStatus()) && !"없음".equals(r.getGiftCategory())) {
&& r.getGiftStatus() != null && !r.getGiftStatus().isBlank()
&& !"미지급".equals(r.getGiftStatus()) && !"없음".equals(r.getGiftCategory())) {
r.setGiftCashAmount(r.getAmount());
}
if (r.getIspName() == null || r.getIspName().isBlank()) r.setIspName(r.getCarrier());
@@ -2962,4 +2983,485 @@ class DataInitializer implements CommandLineRunner {
em.persist(s);
return s;
}
/**
* billing_react 정산현황 테스트용 샘플.
* 영업점/입력처 각각 가정산·정산대기·정산완료·송금(입금)완료 단계를 채운다.
*/
private void seedCalculateSamplesIfEmpty() {
Long exists = em.createQuery(
"select count(r) from HomesReception r where r.groupId='demo' and r.orderNumber like 'CALC-%'",
Long.class)
.getSingleResult();
if (exists != null && exists > 0) return;
LocalDateTime now = LocalDateTime.now(ZoneId.of("Asia/Seoul"));
String[][] branches = {
{"강남점", "본사그룹", "서울", "김상담", "박처리", "1599-1001"},
{"서초점", "본사그룹", "경기", "이상담", "최처리", "1599-1002"},
{"송파점", "동부그룹", "서울", "정상담", "이처리", "1599-1003"},
};
String[] carriers = {"KT", "SKB", "LGU+"};
String[] products = {"기가인터넷 1G", "B tv 프리미엄", "U+ 인터넷", "안심 인터넷"};
int seq = 1;
// ---- 영업점: 가정산 (cost 없음, f23 / 환수 f19·f22) ----
for (String[] b : branches) {
for (int i = 0; i < 3; i++) {
HomesReception r = calcReception(
"CALC-" + String.format("%04d", seq++),
now.minusDays(3 + i), b, carriers[i % 3], products[i % 4],
"정산고객" + seq, "f23", "h01", 100000 + i * 10000, true);
em.persist(r);
}
HomesReception claw = calcReception(
"CALC-" + String.format("%04d", seq++),
now.minusDays(20), b, carriers[0], products[0],
"환수고객" + seq, "f19", "h05", 80000, true);
claw.setPrevProgressStatus("f14");
em.persist(claw);
}
// ---- 영업점: 정산대기 (cost 있음, group 없음) ----
for (String[] b : branches) {
for (int i = 0; i < 2; i++) {
HomesReception r = calcReception(
"CALC-" + String.format("%04d", seq++),
now.minusDays(10 + i), b, carriers[i % 3], products[i % 4],
"대기고객" + seq, "f23", "h01", 120000 + i * 5000, true);
em.persist(r);
em.flush();
HomesReceptionCost c = calcCost(r, "i01", 1);
em.persist(c);
em.flush();
r.setReceptionCostId(c.getId());
}
HomesReception claw = calcReception(
"CALC-" + String.format("%04d", seq++),
now.minusDays(25), b, carriers[1], products[1],
"대기환수" + seq, "f22", "h08", 70000, true);
claw.setPrevProgressStatus("f14");
em.persist(claw);
em.flush();
HomesReceptionCost cc = calcCost(claw, "i05", -1);
em.persist(cc);
em.flush();
claw.setReceptionCostId(cc.getId());
}
// ---- 영업점: 정산완료 그룹 (미송금) ----
for (String[] b : branches) {
HomesReceptionCostGroup g = new HomesReceptionCostGroup();
g.setGroupId("demo");
g.setBranchName(b[0]);
g.setPlusAmt(5000);
g.setMinusAmt(0);
g.setIsTax(1);
g.setRate(BigDecimal.valueOf(10));
g.setCompletedAt(now.minusDays(2));
g.setMemo("샘플 정산완료 — " + b[0]);
g.setBank("국민");
g.setAccountNumber("123-456-" + b[0].hashCode() % 10000);
g.setAccountName(b[0] + "대표");
em.persist(g);
em.flush();
int lineSum = 0;
for (int i = 0; i < 2; i++) {
HomesReception r = calcReception(
"CALC-" + String.format("%04d", seq++),
now.minusDays(15 + i), b, carriers[i % 3], products[i % 4],
"완료고객" + seq, "f24", "h02", 110000 + i * 8000, true);
em.persist(r);
em.flush();
HomesReceptionCost c = calcCost(r, "i01", 1);
c.setCostGroupId(g.getId());
em.persist(c);
em.flush();
r.setReceptionCostId(c.getId());
lineSum += c.getTotal();
}
// 환수 1건
HomesReception claw = calcReception(
"CALC-" + String.format("%04d", seq++),
now.minusDays(30), b, carriers[2], products[2],
"완료환수" + seq, "f26", "h09", 50000, true);
em.persist(claw);
em.flush();
HomesReceptionCost cc = calcCost(claw, "i02", -1);
cc.setCostGroupId(g.getId());
em.persist(cc);
em.flush();
claw.setReceptionCostId(cc.getId());
lineSum += cc.getTotal();
g.setTotal(g.getPlusAmt() + g.getMinusAmt() + lineSum);
}
// ---- 영업점: 송금완료 그룹 ----
for (String[] b : branches) {
HomesReceptionCostGroup g = new HomesReceptionCostGroup();
g.setGroupId("demo");
g.setBranchName(b[0]);
g.setPlusAmt(0);
g.setMinusAmt(0);
g.setIsTax(1);
g.setRate(BigDecimal.valueOf(10));
g.setTaxDate(now.minusDays(8));
g.setCompletedAt(now.minusDays(7));
g.setTransferredAt(now.minusDays(5));
g.setMemo("샘플 송금완료 — " + b[0]);
g.setBank("신한");
g.setAccountNumber("110-222-" + Math.abs(b[0].hashCode() % 10000));
g.setAccountName(b[0]);
em.persist(g);
em.flush();
int lineSum = 0;
for (int i = 0; i < 2; i++) {
HomesReception r = calcReception(
"CALC-" + String.format("%04d", seq++),
now.minusDays(40 + i), b, carriers[i % 3], products[i % 4],
"송금고객" + seq, "f25", "h03", 130000 + i * 7000, true);
em.persist(r);
em.flush();
HomesReceptionCost c = calcCost(r, "i01", 1);
c.setCostGroupId(g.getId());
em.persist(c);
em.flush();
r.setReceptionCostId(c.getId());
lineSum += c.getTotal();
}
g.setTotal(lineSum);
}
// ---- 입력처: 가정산 (center cost 없음) — 영업점 대기와 별도 접수 ----
for (String[] b : branches) {
for (int i = 0; i < 2; i++) {
HomesReception r = calcReception(
"CALC-" + String.format("%04d", seq++),
now.minusDays(4 + i), b, carriers[i % 3], products[i % 4],
"입력가정산" + seq, "f14", "h01", 90000 + i * 3000, true);
// 영업점 정산과는 분리: 개통완료+입력처 정산대기
em.persist(r);
}
HomesReception claw = calcReception(
"CALC-" + String.format("%04d", seq++),
now.minusDays(18), b, carriers[0], products[0],
"입력환수" + seq, "f14", "h05", 60000, true);
em.persist(claw);
}
// ---- 입력처: 정산대기 ----
for (String[] b : branches) {
for (int i = 0; i < 2; i++) {
HomesReception r = calcReception(
"CALC-" + String.format("%04d", seq++),
now.minusDays(12 + i), b, carriers[i % 3], products[i % 4],
"입력대기" + seq, "f14", "h01", 95000 + i * 4000, true);
em.persist(r);
em.flush();
HomesReceptionCenterCost c = calcCenterCost(r, "k01", 1);
em.persist(c);
em.flush();
r.setReceptionCenterCostId(c.getId());
}
}
// ---- 입력처: 정산완료 그룹 ----
for (String[] b : branches) {
HomesReceptionCenterCostGroup g = new HomesReceptionCenterCostGroup();
g.setGroupId("demo");
g.setCenterName(b[4]); // processor
g.setPlusAmt(3000);
g.setMinusAmt(0);
g.setIsTax(1);
g.setRate(BigDecimal.valueOf(10));
g.setCompletedAt(now.minusDays(3));
g.setMemo("샘플 입력처 정산완료 — " + b[4]);
em.persist(g);
em.flush();
int lineSum = 0;
for (int i = 0; i < 2; i++) {
HomesReception r = calcReception(
"CALC-" + String.format("%04d", seq++),
now.minusDays(16 + i), b, carriers[i % 3], products[i % 4],
"입력완료" + seq, "f14", "h02", 88000 + i * 2000, true);
em.persist(r);
em.flush();
HomesReceptionCenterCost c = calcCenterCost(r, "k01", 1);
c.setCostGroupId(g.getId());
em.persist(c);
em.flush();
r.setReceptionCenterCostId(c.getId());
lineSum += c.getTotal();
}
g.setTotal(g.getPlusAmt() + lineSum);
}
// ---- 입력처: 입금완료 그룹 ----
for (String[] b : branches) {
HomesReceptionCenterCostGroup g = new HomesReceptionCenterCostGroup();
g.setGroupId("demo");
g.setCenterName(b[4]);
g.setPlusAmt(0);
g.setMinusAmt(0);
g.setIsTax(1);
g.setRate(BigDecimal.valueOf(10));
g.setCompletedAt(now.minusDays(9));
g.setTransferredAt(now.minusDays(6));
g.setMemo("샘플 입금완료 — " + b[4]);
em.persist(g);
em.flush();
int lineSum = 0;
HomesReception r = calcReception(
"CALC-" + String.format("%04d", seq++),
now.minusDays(35), b, carriers[0], products[0],
"입력입금" + seq, "f14", "h03", 77000, true);
em.persist(r);
em.flush();
HomesReceptionCenterCost c = calcCenterCost(r, "k01", 1);
c.setCostGroupId(g.getId());
em.persist(c);
em.flush();
r.setReceptionCenterCostId(c.getId());
lineSum += c.getTotal();
g.setTotal(lineSum);
}
em.flush();
}
private HomesReception calcReception(
String orderNo, LocalDateTime orderDate, String[] branch,
String carrier, String product, String customer,
String progress, String settle, int amount, boolean opened) {
HomesReception r = new HomesReception();
r.setGroupId("demo");
r.setOrderDate(orderDate);
r.setCounselor(branch[3]);
r.setBranch(branch[0]);
r.setBranchGroup(branch[1]);
r.setBranchPhone(branch[5]);
r.setRegion(branch[2]);
r.setProcessor(branch[4]);
r.setCarrier(carrier);
r.setIspName(carrier);
r.setGiftCategory("상품권");
r.setProductCategory("인터넷");
r.setProductType("e01");
r.setProductName(product);
r.setCustomerType("public");
r.setCustomerName(customer);
r.setPhone("010-" + String.format("%04d", Math.abs(orderNo.hashCode() % 10000)) + "-"
+ String.format("%04d", Math.abs(customer.hashCode() % 10000)));
r.setProgressStatus(progress);
r.setOrderNumber(orderNo);
r.setContractNumber(orderNo.replace("CALC-", "CT-"));
r.setInboundInfo("서울입고");
r.setDetailProgress("정산샘플");
r.setPaymentType("현금");
r.setPaymentMethod("transfer");
r.setAmount(BigDecimal.valueOf(amount));
r.setSettleStatus(settle);
if (opened) {
r.setOpenDate(orderDate.toLocalDate().minusDays(1));
r.setGiftDate(orderDate.toLocalDate());
r.setGiftStatus("g07");
r.setGiftPlace("b01");
r.setGiftSended(orderDate.toLocalDate());
}
r.setAgreementName("36개월");
r.setMemo("정산현황 테스트 샘플 (" + progress + "/" + settle + ")");
r.setPostIt("");
return r;
}
private HomesReceptionCost calcCost(HomesReception r, String type, int sign) {
HomesReceptionCost c = new HomesReceptionCost();
c.setGroupId("demo");
c.setReceptionId(r.getId());
c.setType(type);
c.setTypeDesc(ReceptionStatuses.COST_TYPE.getOrDefault(type, ""));
int base = r.getAmount() == null ? 0 : r.getAmount().intValue();
c.setCost(base * sign);
c.setEventAmt(0);
c.setAgreementAmt(0);
c.setLocalAmt(0);
// 사은품 차감(본사발송 가정)
int gift = ("g07".equals(r.getGiftStatus()) ? -10000 : 0) * sign;
c.setGiftAmt(gift);
c.setVocAmt(0);
c.setEtcAmt(0);
c.setTotal(c.getCost() + c.getGiftAmt());
c.setMemo("샘플 정산라인");
return c;
}
private HomesReceptionCenterCost calcCenterCost(HomesReception r, String type, int sign) {
HomesReceptionCenterCost c = new HomesReceptionCenterCost();
c.setGroupId("demo");
c.setReceptionId(r.getId());
c.setType(type);
c.setTypeDesc(ReceptionStatuses.CENTER_COST_TYPE.getOrDefault(type, ""));
int base = r.getAmount() == null ? 0 : r.getAmount().intValue();
// 입력처 수수료는 영업점보다 낮게
c.setCost((base / 2) * sign);
c.setEventAmt(0);
c.setAgreementAmt(0);
c.setLocalAmt(0);
c.setGiftAmt(0);
c.setVocAmt(0);
c.setEtcAmt(0);
c.setTotal(c.getCost());
c.setMemo("샘플 입력처 정산라인");
return c;
}
/**
* 수수료 현황 팝업 테스트용 — billing_imeere branch_fees / center_fees 샘플.
* 서초점·강남점 등은 수수료 있음, 일부 통신사 조합은 없음으로 "적용가능한 수수료가 없습니다" 검증.
*/
private void seedFeeSheetsIfEmpty() {
Long n = em.createQuery("select count(f) from HomesBranchFee f where f.groupId='demo'", Long.class)
.getSingleResult();
if (n != null && n > 0) return;
LocalDateTime start = LocalDateTime.of(2024, 1, 1, 0, 0);
String[][] branchFees = {
{"alone", "강남점", "본사그룹", "KT"},
{"alone", "서초점", "본사그룹", "KT"},
{"alone", "서초점", "본사그룹", "SKB"},
{"alone", "서초점", "본사그룹", "LGU+"},
{"alone", "송파점", "동부그룹", "KT"},
{"alone", "송파점", "동부그룹", "SKB"},
{"group", "", "본사그룹", "KT"},
{"group", "", "동부그룹", "SKB"},
};
Object[][] products = {
{"e01", "기가인터넷 1G", 100000},
{"e01", "안심 인터넷", 80000},
{"e01", "B tv 인터넷", 90000},
{"e01", "U+ 인터넷", 95000},
{"e03", "B tv 프리미엄", 50000},
{"e02", "집전화 스마트", 20000},
};
for (String[] bf : branchFees) {
HomesBranchFee fee = new HomesBranchFee();
fee.setGroupId("demo");
fee.setFeeType(bf[0]);
fee.setBranchName(blank(bf[1]) ? null : bf[1]);
fee.setBranchGroupName(blank(bf[2]) ? null : bf[2]);
fee.setCarrier(bf[3]);
fee.setStartDate(start);
fee.setMemo("샘플 수수료 (" + bf[0] + "/" + bf[3] + ")");
em.persist(fee);
em.flush();
for (Object[] p : products) {
if (!String.valueOf(p[1]).contains(bf[3].substring(0, 1))
&& !"KT".equals(bf[3]) && !"e01".equals(p[0])) {
// keep most products for all carriers
}
HomesBranchFeeProduct row = new HomesBranchFeeProduct();
row.setGroupId("demo");
row.setFeeId(fee.getId());
row.setProductType(String.valueOf(p[0]));
row.setProductName(String.valueOf(p[1]));
row.setFee((Integer) p[2]);
em.persist(row);
}
}
String[] centers = {"박처리", "최처리", "이처리"};
String[] carriers = {"KT", "SKB", "LGU+"};
for (String center : centers) {
for (String carrier : carriers) {
// 최처리+LGU+ 는 의도적으로 제외 → 없음 알림 테스트
if ("최처리".equals(center) && "LGU+".equals(carrier)) continue;
HomesCenterFee fee = new HomesCenterFee();
fee.setGroupId("demo");
fee.setCenterName(center);
fee.setCarrier(carrier);
fee.setStartDate(start);
fee.setMemo("샘플 입력처 수수료");
em.persist(fee);
em.flush();
for (Object[] p : products) {
HomesCenterFeeProduct row = new HomesCenterFeeProduct();
row.setGroupId("demo");
row.setFeeId(fee.getId());
row.setProductType(String.valueOf(p[0]));
row.setProductName(String.valueOf(p[1]));
row.setFee(((Integer) p[2]) / 2);
em.persist(row);
}
}
}
em.flush();
}
/** 영업점관리 목록 — 정산/수수료 샘플과 동일 영업점명 */
private void seedBranchesIfEmpty() {
Long n = em.createQuery("select count(b) from HomesBranch b where b.groupId='demo'", Long.class)
.getSingleResult();
if (n != null && n > 0) return;
HomesBranchGroup g1 = new HomesBranchGroup();
g1.setGroupId("demo");
g1.setName("본사그룹");
g1.setSeq(1);
em.persist(g1);
HomesBranchGroup g2 = new HomesBranchGroup();
g2.setGroupId("demo");
g2.setName("동부그룹");
g2.setSeq(2);
em.persist(g2);
em.flush();
Object[][] rows = {
// officeName, name, userid, sido, groupId, level, grant, phone, isTax, hasBiz, email
{"강남점", "강남대표", "gangnam", "서울", g1.getId(), 1, 1, "010-1111-1001", 1, true, "gangnam@demo.local"},
{"서초점", "서초대표", "seocho", "서울", g1.getId(), 1, 1, "010-2222-1002", 1, false, "seocho@demo.local"},
{"송파점", "송파대표", "songpa", "서울", g2.getId(), 1, 1, "010-3333-1003", 0, true, "songpa@demo.local"},
{"딜러스", "딜러스", "dealers", "대구", g2.getId(), 1, 1, "010-4444-2001", 1, false, "dealers@demo.local"},
{"미승인점", "대기초기", "pending1", "부산", g1.getId(), 1, 0, "010-5555-3001", 1, false, "pending@demo.local"},
{"박처리", "박처리", "center_park", "서울", null, 5, 1, "010-6666-4001", 1, false, "park@demo.local"},
};
for (Object[] r : rows) {
HomesBranch b = new HomesBranch();
b.setGroupId("demo");
b.setOfficeName(String.valueOf(r[0]));
b.setName(String.valueOf(r[1]));
b.setUserid(String.valueOf(r[2]));
b.setPasswordHash(encoder.encode("demo1234"));
b.setSido(String.valueOf(r[3]));
b.setBranchGroupId(r[4] == null ? null : (Long) r[4]);
b.setLevel((Integer) r[5]);
b.setGrantFlag((Integer) r[6]);
b.setPhone(String.valueOf(r[7]));
b.setTel("02-000-" + String.valueOf(r[2]).substring(0, Math.min(4, String.valueOf(r[2]).length())));
b.setIsTax((Integer) r[8]);
b.setHasBusinessLicense((Boolean) r[9]);
b.setEmail(String.valueOf(r[10]));
b.setAddress(b.getSido() + " " + b.getOfficeName() + " 1길 1");
b.setZipcode("06234");
b.setAccountBank("국민은행");
b.setAccountNumber("123-456-7890");
b.setAccountName(b.getName());
if (Integer.valueOf(5).equals(b.getLevel())) {
b.setCenterName(b.getOfficeName());
}
if (b.isHasBusinessLicense()) {
b.setLicenseNumber("123-45-67890");
b.setLicenseType("도소매");
b.setLicenseItem("통신판매");
}
em.persist(b);
}
em.flush();
}
private static boolean blank(String s) { return s == null || s.isBlank(); }
}
@@ -456,6 +456,8 @@ class HomesReception extends BaseEntity {
String region;
/** 진행상황 */
String progressStatus;
/** 직전 진행상태 (가정산 취소 복원용) */
String prevProgressStatus;
/** 주문번호 */
String orderNumber;
/** 연결된 ISP id */
@@ -535,6 +537,92 @@ class HomesReception extends BaseEntity {
String adminFile1;
String adminFile2;
String adminFile3;
/** 영업점 정산 cost FK (가정산→정산대기 시 연결) */
Long receptionCostId;
/** 입력처 정산 cost FK */
Long receptionCenterCostId;
}
/** 접수별 영업점 정산 비용 — billing order_costs */
@Entity @Table(name = "homes_reception_costs") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesReceptionCost extends BaseEntity {
String type;
String typeDesc;
Integer cost = 0;
Integer eventAmt = 0;
Integer agreementAmt = 0;
Integer localAmt = 0;
Integer giftAmt = 0;
Integer vocAmt = 0;
Integer etcAmt = 0;
Integer total = 0;
@Column(columnDefinition = "TEXT") String progressMemo;
@Column(columnDefinition = "TEXT") String memo;
Long receptionId;
Long costGroupId;
}
/** 영업점 정산 그룹 — billing order_cost_groups */
@Entity @Table(name = "homes_reception_cost_groups") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesReceptionCostGroup extends BaseEntity {
String branchName;
Integer plusAmt = 0;
Integer minusAmt = 0;
Integer total = 0;
/** 세율(%) — 세금계산서 10 / 종합소득 -3.3 */
java.math.BigDecimal rate = java.math.BigDecimal.ZERO;
/** 1=세금계산서발행, 0=종합소득세공제 */
Integer isTax = 1;
LocalDateTime taxDate;
LocalDateTime completedAt;
LocalDateTime transferredAt;
String bank;
String accountNumber;
String accountName;
@Column(columnDefinition = "TEXT") String memo;
String file1;
String file2;
String file3;
}
/** 접수별 입력처 정산 비용 — billing order_center_costs */
@Entity @Table(name = "homes_reception_center_costs") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesReceptionCenterCost extends BaseEntity {
String type;
String typeDesc;
Integer cost = 0;
Integer eventAmt = 0;
Integer agreementAmt = 0;
Integer localAmt = 0;
Integer giftAmt = 0;
Integer vocAmt = 0;
Integer etcAmt = 0;
Integer total = 0;
@Column(columnDefinition = "TEXT") String progressMemo;
@Column(columnDefinition = "TEXT") String memo;
Long receptionId;
Long costGroupId;
}
/** 입력처 정산 그룹 — billing order_center_cost_groups */
@Entity @Table(name = "homes_reception_center_cost_groups") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesReceptionCenterCostGroup extends BaseEntity {
String centerName;
Integer plusAmt = 0;
Integer minusAmt = 0;
Integer total = 0;
java.math.BigDecimal rate = java.math.BigDecimal.ZERO;
Integer isTax = 1;
LocalDateTime taxDate;
LocalDateTime completedAt;
LocalDateTime transferredAt;
String bank;
String accountNumber;
String accountName;
@Column(columnDefinition = "TEXT") String memo;
String file1;
String file2;
String file3;
}
/** 접수 진행 이력 */
@@ -585,3 +673,168 @@ class HomesReceptionIspAgreement extends BaseEntity {
String name;
Boolean active;
}
/** 영업점 그룹 — billing branch_groups */
@Entity @Table(name = "homes_branch_groups") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesBranchGroup extends BaseEntity {
String name;
Integer seq = 0;
}
/**
* 영업점 마스터 — billing Branch + 상세(계좌/사업자/권한 등)를 한 테이블로 축소.
* officeName 은 접수·수수료의 branch / branchName 문자열과 맞춤.
*/
@Entity @Table(name = "homes_branches") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesBranch extends BaseEntity {
String name;
String officeName;
String userid;
String passwordHash;
String tel;
String phone;
String fax;
String email;
String zipcode;
String address;
String addressDetail;
/** 지역(시/도) */
String sido;
/** 1=영업점, 5=센터, 9=관리자, 10=최고관리자 */
Integer level = 1;
/** 1=승인, 0=미승인(거부) */
Integer grantFlag = 0;
Long branchGroupId;
/** 레벨=센터일 때 입력처명 */
String centerName;
boolean hasBusinessLicense = false;
Integer isTax = 1;
String accountBank;
String accountNumber;
String accountName;
String contactPhone;
String contactNateon;
Integer acceptPhone = 0;
Integer acceptNateon = 0;
@Column(columnDefinition = "TEXT") String memo;
@Column(columnDefinition = "TEXT") String adminMemo;
String licenseNumber;
String licenseType;
String licenseItem;
/** JSON 배열 문자열, 최대 6개 */
@Column(columnDefinition = "TEXT") String notifyPhonesJson;
boolean permissionOrderDateUpdate = false;
boolean permissionCalculate = false;
boolean permissionConfig = false;
boolean permissionExcelDownload = false;
boolean permissionChart = false;
}
/**
* 영업점 수수료 시트 — billing branch_fees / branch_group_fees 축소.
* BillCare는 영업점명을 문자열로 쓰므로 branchName 키.
*/
@Entity @Table(name = "homes_branch_fees") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesBranchFee extends BaseEntity {
/** alone=개별, group=그룹 */
String feeType;
String branchName;
String branchGroupName;
String carrier;
LocalDateTime startDate;
@Column(columnDefinition = "TEXT") String memo;
/** 첨부 표시용 파일명 (업로드는 선택 구현) */
String file1;
String file2;
String file3;
String file4;
String file5;
}
@Entity @Table(name = "homes_branch_fee_products") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesBranchFeeProduct extends BaseEntity {
Long feeId;
String productType;
String productName;
Integer fee = 0;
}
/** 입력처 수수료 시트 — billing center_fees */
@Entity @Table(name = "homes_center_fees") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesCenterFee extends BaseEntity {
String centerName;
String carrier;
LocalDateTime startDate;
@Column(columnDefinition = "TEXT") String memo;
}
@Entity @Table(name = "homes_center_fee_products") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesCenterFeeProduct extends BaseEntity {
Long feeId;
String productType;
String productName;
Integer fee = 0;
}
/** 통신사 그룹 */
@Entity @Table(name="homes_isp_groups") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesIspGroup extends BaseEntity {
String name;
Integer seq = 0;
}
/** 통신사 인증링크 */
@Entity @Table(name="homes_isp_links") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesIspLink extends BaseEntity {
Long ispId;
String name;
String url;
Integer seq = 0;
Boolean active = true;
}
/** 입력처(센터) 마스터 — billing Center */
@Entity @Table(name="homes_input_centers") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesInputCenter extends BaseEntity {
String name;
String tel;
String phone;
String manager;
Integer seq = 0;
Boolean active = true;
String accountBank;
String accountNumber;
String accountName;
@Column(columnDefinition="TEXT") String memo;
String file1; String file2; String file3; String file4; String file5;
}
/** 게시판 정의 — billing Board (HomesBbs는 게시글) */
@Entity @Table(name="homes_boards") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesBoard extends BaseEntity {
String name;
Integer listLevel = 1;
Integer readLevel = 1;
Integer writeLevel = 9;
Integer seq = 0;
Boolean active = true;
}
/** 퀵전송 입력처 인증 */
@Entity @Table(name="homes_center_quick_auths") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesCenterQuickAuth extends BaseEntity {
Long centerId;
String userid;
String passwordEnc;
String baseUrl;
Boolean authorized = false;
}
/** 퀵전송 진행알림 로그 */
@Entity @Table(name="homes_quick_delivery_logs") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
class HomesQuickDeliveryLog extends BaseEntity {
String name;
String status;
@Column(columnDefinition="TEXT") String memo;
Long orderQuickDeliveryClientId;
}
@@ -0,0 +1,215 @@
package com.bizcare.agent;
import java.util.*;
/**
* billing_react AppConstant 의 접수/사은품/입력처/정산 상태·전이를 BillCare 에 이식.
*/
public final class ReceptionStatuses {
private ReceptionStatuses() {}
public static final List<String> PRE_CALC_STATUSES =
List.of("f19", "f20", "f21", "f22", "f28", "f23");
public static final List<String> CENTER_PRE_CALC_STATUSES =
List.of("h01", "h05", "h06", "h07", "h08", "h11");
public static final Map<String, String> PROGRESS = map(
"f01", "접수", "f02", "재접수", "f03", "접수진행중", "f04", "접수완료",
"f05", "승인대기중", "f06", "회신요청", "f07", "접수반송", "f08", "미개통오더반송",
"f09", "TC실패반송", "f10", "접수보류", "f11", "접수취소", "f12", "개통중",
"f13", "기사완료", "f14", "개통완료", "f15", "개통보류", "f16", "개통취소",
"f17", "개통불가", "f18", "정산보류", "f19", "위면해지", "f20", "직권해지",
"f21", "유보중단환수", "f22", "해지", "f23", "정산대기", "f24", "정산완료",
"f25", "송금완료", "f26", "환수정산완료", "f27", "환수완료",
"f28", "VOC(귀책)차감", "f29", "VOC(귀책)차감완료");
public static final Map<String, String> GIFT = map(
"g01", "지급요청", "g02", "지급재요청", "g03", "반송", "g04", "처리중",
"g05", "지급보류", "g06", "지급취소", "g07", "지급완료", "g08", "회수요청",
"g09", "회수(반환)완료", "g10", "등록완료", "g11", "삭제완료", "g12", "입금증요청",
"g13", "입금증완료", "g14", "입금증첨부완료", "g15", "입금증(센터)전달완료");
public static final Map<String, String> CENTER = map(
"h01", "정산대기", "h02", "정산완료", "h03", "입금완료", "h04", "정산보류",
"h05", "위면해지", "h06", "직권해지", "h07", "유보중단환수", "h08", "해지",
"h09", "해지정산완료", "h10", "해지송금완료", "h11", "VOC(귀책)송금", "h12", "VOC(귀책)송금완료");
public static final Map<String, String> GIFT_PLACE = map(
"b02", "자체발송", "b01", "본사발송", "b03", "본사(센터)", "b04", "본사(관리자)");
public static final Map<String, String> COST_TYPE = map(
"i01", "정산", "i02", "위면해지", "i03", "직권해지", "i04", "유보중단환수", "i05", "해지", "i06", "voc차감");
public static final Map<String, String> CENTER_COST_TYPE = map(
"k01", "정산", "k02", "위면해지", "k03", "직권해지", "k04", "유보중단환수", "k05", "해지", "k06", "voc차감");
/** 구 BillCare 한글 라벨 → 코드 */
public static final Map<String, String> LEGACY_PROGRESS = Map.ofEntries(
Map.entry("1.접수완료", "f04"),
Map.entry("2.고객통화", "f03"),
Map.entry("3.서류검토", "f05"),
Map.entry("4.개통완료", "f14"),
Map.entry("5.사은품지급", "f14"),
Map.entry("6.정산완료", "f24"),
Map.entry("9.취소", "f11"),
Map.entry("접수", "f01"),
Map.entry("접수완료", "f04"),
Map.entry("개통완료", "f14"),
Map.entry("정산대기", "f23"),
Map.entry("정산완료", "f24"),
Map.entry("송금완료", "f25"),
Map.entry("접수취소", "f11")
);
public static final Map<String, String> LEGACY_GIFT = Map.of(
"미지급", "",
"지급대기", "g01",
"지급요청", "g01",
"지급완료", "g07",
"반송", "g03"
);
public static final Map<String, String> LEGACY_CENTER = Map.of(
"정산대기", "h01",
"정산완료", "h02",
"입금완료", "h03",
"회수대기", "h05",
"회수완료", "h09",
"미지정", "h01"
);
public static List<String> progressCodes() {
return new ArrayList<>(PROGRESS.keySet());
}
public static List<String> giftCodes() {
return new ArrayList<>(GIFT.keySet());
}
public static List<String> centerCodes() {
return new ArrayList<>(CENTER.keySet());
}
public static String progressLabel(String code) {
if (code == null || code.isBlank()) return "";
return PROGRESS.getOrDefault(code, code);
}
public static String giftLabel(String code) {
if (code == null || code.isBlank()) return "";
return GIFT.getOrDefault(code, code);
}
public static String centerLabel(String code) {
if (code == null || code.isBlank()) return "";
return CENTER.getOrDefault(code, code);
}
public static String normalizeProgress(String raw) {
if (raw == null || raw.isBlank()) return "";
if (PROGRESS.containsKey(raw)) return raw;
return LEGACY_PROGRESS.getOrDefault(raw, raw);
}
public static String normalizeGift(String raw) {
if (raw == null) return "";
if (raw.isBlank() || "미지급".equals(raw)) return "";
if (GIFT.containsKey(raw)) return raw;
return LEGACY_GIFT.getOrDefault(raw, raw);
}
public static String normalizeCenter(String raw) {
if (raw == null || raw.isBlank()) return "";
if (CENTER.containsKey(raw)) return raw;
return LEGACY_CENTER.getOrDefault(raw, raw);
}
public static Set<String> orderStatusTransitions(String current) {
if (current == null) return Set.of();
return switch (current) {
case "f01", "f02", "f03", "f04", "f05", "f06", "f07", "f08", "f09", "f10", "f11", "f12",
"f13", "f15", "f16", "f17" ->
set("f01", "f02", "f03", "f04", "f05", "f06", "f07", "f08", "f09",
"f10", "f11", "f12", "f13", "f14", "f15", "f16", "f17", "f18");
case "f18", "f14" ->
set("f01", "f02", "f03", "f04", "f05", "f06", "f07", "f08", "f09",
"f10", "f11", "f12", "f13", "f14", "f15", "f16", "f17", "f18",
"f19", "f20", "f21", "f22", "f23");
case "f23" -> set("f23");
case "f24" -> set("f24");
case "f25" -> set("f25", "f19", "f20", "f21", "f22", "f28");
case "f19" -> set("f19");
case "f20" -> set("f20");
case "f21" -> set("f21");
case "f22" -> set("f22");
case "f28" -> set("f28");
case "f29" -> set("f28", "f29", "f19", "f20", "f21", "f22");
case "f26" -> set("f26");
case "f27" -> set("f27");
default -> Set.of();
};
}
public static Set<String> centerStatusTransitions(String current) {
if (current == null || current.isBlank()) return set("h01");
return switch (current) {
case "h01" -> set("h01");
case "h02" -> set("h02");
case "h03" -> set("h03", "h05", "h06", "h07", "h08", "h11");
case "h04" -> set("h04", "h01");
case "h05" -> set("h05");
case "h06" -> set("h06");
case "h07" -> set("h07");
case "h08" -> set("h08");
case "h09" -> set("h09");
case "h10" -> set("h10");
case "h11" -> set("h11");
case "h12" -> set("h05", "h06", "h07", "h08", "h11");
default -> Set.of();
};
}
public static String orderCostType(String orderStatus) {
if (orderStatus == null) return "";
return switch (orderStatus) {
case "f19" -> "i02";
case "f20" -> "i03";
case "f21" -> "i04";
case "f22" -> "i05";
case "f28" -> "i06";
case "f23" -> "i01";
default -> "";
};
}
public static String centerCostType(String status) {
if (status == null) return "";
return switch (status) {
case "h05" -> "k02";
case "h06" -> "k03";
case "h07" -> "k04";
case "h08" -> "k05";
case "h11" -> "k06";
case "h01" -> "k01";
default -> "";
};
}
public static boolean isPreCalc(String status) {
return status != null && PRE_CALC_STATUSES.contains(status);
}
public static boolean isCenterPreCalc(String status) {
return status != null && CENTER_PRE_CALC_STATUSES.contains(status);
}
private static Map<String, String> map(String... kv) {
Map<String, String> m = new LinkedHashMap<>();
for (int i = 0; i < kv.length; i += 2) m.put(kv[i], kv[i + 1]);
return Collections.unmodifiableMap(m);
}
private static Set<String> set(String... v) {
return new LinkedHashSet<>(Arrays.asList(v));
}
}
+28
View File
@@ -23,6 +23,8 @@ import ContractAdjustPage from './pages/care/ContractAdjustPage'
import ContractBalancePage from './pages/care/ContractBalancePage'
import ContractWritePage from './pages/care/ContractWritePage'
import ReceptionListPage from './pages/care/ReceptionListPage'
import BranchListPage from './pages/care/BranchListPage'
import CalculatePage from './pages/care/CalculatePage'
import InvoicePublishPage from './pages/care/InvoicePublishPage'
import InvoiceMyPage from './pages/care/InvoiceMyPage'
import InvoiceLogPage from './pages/care/InvoiceLogPage'
@@ -48,6 +50,14 @@ import SettingPreferencePage from './pages/care/SettingPreferencePage'
import SettingLevelPage from './pages/care/SettingLevelPage'
import SettingPartner1Page from './pages/care/SettingPartner1Page'
import SettingPartner2Page from './pages/care/SettingPartner2Page'
import SettingIspPage from './pages/care/SettingIspPage'
import SettingCenterPage from './pages/care/SettingCenterPage'
import SettingIspLinkPage from './pages/care/SettingIspLinkPage'
import SettingBoardAdminPage from './pages/care/SettingBoardAdminPage'
import SettingFeePage from './pages/care/SettingFeePage'
import SettingQuickDeliveryPage from './pages/care/SettingQuickDeliveryPage'
import SettingConfigurePage from './pages/care/SettingConfigurePage'
import SettingUsageReportPage from './pages/care/SettingUsageReportPage'
import CsNoticePage from './pages/care/CsNoticePage'
import CsQuestionPage from './pages/care/CsQuestionPage'
import BbsPage from './pages/care/BbsPage'
@@ -70,6 +80,8 @@ export default function App() {
token: {
colorPrimary: THEME_PRIMARY[theme] || '#1677ff',
colorBgContainer: theme === 'light' ? '#ffffff' : theme === 'blue' ? '#15273d' : '#24283b',
colorBgElevated: theme === 'light' ? '#ffffff' : theme === 'blue' ? '#15273d' : '#24283b',
colorBgLayout: theme === 'light' ? '#f1f5f9' : theme === 'blue' ? '#0a1522' : '#16181e',
colorTextLightSolid: '#ffffff',
colorBorder: theme === 'light' ? undefined : theme === 'blue' ? 'rgba(143, 187, 224, 0.55)' : 'rgba(192, 202, 245, 0.5)',
colorBorderSecondary: theme === 'light' ? undefined : theme === 'blue' ? 'rgba(143, 187, 224, 0.35)' : 'rgba(192, 202, 245, 0.32)',
@@ -80,6 +92,9 @@ export default function App() {
defaultShadow: 'none',
dangerShadow: 'none',
},
Drawer: {
colorBgElevated: theme === 'light' ? '#ffffff' : theme === 'blue' ? '#15273d' : '#24283b',
},
Checkbox: {
colorBorder: theme === 'light' ? undefined : theme === 'blue' ? 'rgba(143, 187, 224, 0.65)' : 'rgba(192, 202, 245, 0.58)',
},
@@ -114,6 +129,11 @@ export default function App() {
<Route path="reception/list" element={<ReceptionListPage />} />
<Route path="branch/list" element={<BranchListPage />} />
<Route path="calculate/branch" element={<CalculatePage kind="branch" />} />
<Route path="calculate/center" element={<CalculatePage kind="center" />} />
<Route path="invoice/publish" element={<InvoicePublishPage />} />
<Route path="invoice/my" element={<InvoiceMyPage />} />
<Route path="invoice/log" element={<InvoiceLogPage />} />
@@ -145,6 +165,14 @@ export default function App() {
<Route path="setting/level" element={<SettingLevelPage />} />
<Route path="setting/partner1" element={<SettingPartner1Page />} />
<Route path="setting/partner2" element={<SettingPartner2Page />} />
<Route path="setting/isp" element={<SettingIspPage />} />
<Route path="setting/center" element={<SettingCenterPage />} />
<Route path="setting/isp-link" element={<SettingIspLinkPage />} />
<Route path="setting/board-admin" element={<SettingBoardAdminPage />} />
<Route path="setting/fee" element={<SettingFeePage />} />
<Route path="setting/quick-delivery" element={<SettingQuickDeliveryPage />} />
<Route path="setting/configure" element={<SettingConfigurePage />} />
<Route path="setting/usage" element={<SettingUsageReportPage />} />
<Route path="cs/notice" element={<CsNoticePage />} />
<Route path="cs/question" element={<CsQuestionPage />} />
+3 -3
View File
@@ -75,7 +75,7 @@ export default function AgentShell({ shop = false }: Props) {
() => (shop ? allSections : filterMenuSections(allSections, allowedMenus)),
[shop, allSections, allowedMenus],
)
const selectedKey = resolveSelectedKey(location.pathname)
const selectedKey = resolveSelectedKey(location.pathname, location.search)
const [openCards, setOpenCards] = useState<Record<string, boolean>>({})
@@ -91,7 +91,7 @@ export default function AgentShell({ shop = false }: Props) {
})
return next
})
}, [location.pathname, sections])
}, [location.pathname, location.search, sections])
useEffect(() => {
localStorage.setItem('lp.width', String(panelWidth))
@@ -220,7 +220,7 @@ export default function AgentShell({ shop = false }: Props) {
key={it.key}
to={it.key}
title={it.label}
className={({ isActive }) => (isActive || selectedKey === it.key ? 'active' : undefined)}
className={() => (selectedKey === it.key ? 'active' : undefined)}
>
{it.label}
</NavLink>
+80 -35
View File
@@ -207,7 +207,7 @@ function DraggableModal({
titleActions={titleActions}
draggable={draggable}
showClose={showClose}
onClose={(e) => onCancel?.(e)}
onClose={(e) => handleCancel(e)}
onMouseDown={onTitleMouseDown}
/>
)}
@@ -227,37 +227,15 @@ function DraggableModal({
)
}
function StaticDragShell({
function StaticDragWrap({
title,
children,
onTitleMouseDown,
offset,
onClose,
}: {
title?: ReactNode
children: ReactNode
onTitleMouseDown: (e: ReactMouseEvent) => void
offset: { x: number; y: number }
onClose?: () => void
}) {
return (
<div
className="app-modal-drag-root"
style={{ transform: `translate(${offset.x}px, ${offset.y}px)` }}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="app-modal-static-panel">
<TitleBar
title={title}
draggable
showClose={false}
onMouseDown={onTitleMouseDown}
/>
<div className="app-modal-slot">{children}</div>
</div>
</div>
)
}
function StaticDragWrap({ title, children }: { title?: ReactNode; children: ReactNode }) {
const [open] = useState(true)
const { offset, onTitleMouseDown, shouldSuppressClose } = useDraggableOffset(open)
@@ -273,13 +251,61 @@ function StaticDragWrap({ title, children }: { title?: ReactNode; children: Reac
}, [shouldSuppressClose])
return (
<StaticDragShell title={title} offset={offset} onTitleMouseDown={onTitleMouseDown}>
<StaticDragShell
title={title}
offset={offset}
onTitleMouseDown={onTitleMouseDown}
onClose={() => {
if (onClose) onClose()
else AntModal.destroyAll()
}}
>
{children}
</StaticDragShell>
)
}
function wrapStatic(config: ModalFuncProps = {}): ModalFuncProps {
function StaticDragShell({
title,
children,
onTitleMouseDown,
offset,
onClose,
}: {
title?: ReactNode
children: ReactNode
onTitleMouseDown: (e: ReactMouseEvent) => void
offset: { x: number; y: number }
onClose?: () => void
}) {
return (
<div
className="app-modal-drag-root"
style={{ transform: `translate(${offset.x}px, ${offset.y}px)` }}
onMouseDown={(e) => e.stopPropagation()}
>
<div className="app-modal-static-panel">
<TitleBar
title={title}
draggable
showClose={!!onClose}
onClose={(e) => {
e.preventDefault()
e.stopPropagation()
onClose?.()
}}
onMouseDown={onTitleMouseDown}
/>
<div className="app-modal-slot">{children}</div>
</div>
</div>
)
}
function wrapStatic(
config: ModalFuncProps = {},
closer?: { current: (() => void) | null },
): ModalFuncProps {
const userRender = config.modalRender
const title = config.title
return {
@@ -290,18 +316,37 @@ function wrapStatic(config: ModalFuncProps = {}): ModalFuncProps {
className: withModalClass(config.className),
rootClassName: ['app-draggable-modal-root', config.rootClassName].filter(Boolean).join(' '),
modalRender: (node) => {
const wrapped = <StaticDragWrap title={title}>{node}</StaticDragWrap>
const wrapped = (
<StaticDragWrap
title={title}
onClose={() => {
closer?.current?.()
if (!closer?.current) AntModal.destroyAll()
}}
>
{node}
</StaticDragWrap>
)
return userRender ? userRender(wrapped) : wrapped
},
}
}
DraggableModal.confirm = (props?: ModalFuncProps) => AntModal.confirm(wrapStatic(props))
DraggableModal.info = (props?: ModalFuncProps) => AntModal.info(wrapStatic(props))
DraggableModal.success = (props?: ModalFuncProps) => AntModal.success(wrapStatic(props))
DraggableModal.error = (props?: ModalFuncProps) => AntModal.error(wrapStatic(props))
DraggableModal.warning = (props?: ModalFuncProps) => AntModal.warning(wrapStatic(props))
DraggableModal.warn = (props?: ModalFuncProps) => AntModal.warn(wrapStatic(props))
function withStaticCloser(factory: (cfg: ModalFuncProps) => ReturnType<typeof AntModal.info>) {
return (props?: ModalFuncProps) => {
const closer: { current: (() => void) | null } = { current: null }
const api = factory(wrapStatic(props, closer))
closer.current = () => api.destroy()
return api
}
}
DraggableModal.confirm = withStaticCloser((cfg) => AntModal.confirm(cfg))
DraggableModal.info = withStaticCloser((cfg) => AntModal.info(cfg))
DraggableModal.success = withStaticCloser((cfg) => AntModal.success(cfg))
DraggableModal.error = withStaticCloser((cfg) => AntModal.error(cfg))
DraggableModal.warning = withStaticCloser((cfg) => AntModal.warning(cfg))
DraggableModal.warn = withStaticCloser((cfg) => AntModal.warn(cfg))
DraggableModal.destroyAll = AntModal.destroyAll
DraggableModal.useModal = AntModal.useModal
DraggableModal.config = AntModal.config
@@ -5,6 +5,7 @@ import Modal from './DraggableModal'
import { FormField, FormGrid, FormSection } from './OrderFormLayout'
import client, { apiPrefix, unwrap, type ApiResponse } from '../api/client'
import { options } from '../pages/care/homesShared'
import { statusOptions, PROGRESS_STATUS, GIFT_STATUS, GIFT_PLACE } from '../pages/care/receptionStatuses'
type Props = {
open: boolean
@@ -16,12 +17,7 @@ type Props = {
onSuccess: () => void
}
const GIFT_PLACES = [
{ value: 'b01', label: '본사요청' },
{ value: 'b02', label: '자체발송' },
{ value: '본사', label: '본사발송' },
{ value: '영업점', label: '영업점발송' },
]
const GIFT_PLACES = Object.entries(GIFT_PLACE).map(([value, label]) => ({ value, label }))
export default function ReceptionBulkModal({
open,
@@ -176,7 +172,9 @@ export default function ReceptionBulkModal({
placeholder="진행상황 선택"
value={pgStatus}
onChange={(v) => setPgStatus(v)}
options={options(progressStatuses)}
options={statusOptions(
Object.fromEntries((progressStatuses.length ? progressStatuses : Object.keys(PROGRESS_STATUS)).map((c) => [c, PROGRESS_STATUS[c] || c])),
)}
style={{ flex: 1, minWidth: 180 }}
/>
<Checkbox checked={pgNotNotice} onChange={(e) => setPgNotNotice(e.target.checked)}>
@@ -236,7 +234,9 @@ export default function ReceptionBulkModal({
placeholder="사은품 진행상황 선택"
value={gStatus}
onChange={(v) => setGStatus(v)}
options={options(giftStatuses)}
options={statusOptions(
Object.fromEntries((giftStatuses.length ? giftStatuses : Object.keys(GIFT_STATUS)).map((c) => [c, GIFT_STATUS[c] || c])),
)}
/>
</FormField>
<FormField label="사은품진행메모(LOG)" full>
@@ -14,6 +14,7 @@ import Modal from './DraggableModal'
import PrgSection from './PrgSection'
import ReceptionProgressReadOnly from './ReceptionProgressReadOnly'
import client, { apiPrefix, unwrap, type ApiResponse } from '../api/client'
import { progressLabel, giftLabel, statusOptions, PROGRESS_STATUS, GIFT_STATUS, GIFT_PLACE } from '../pages/care/receptionStatuses'
type Props = {
open: boolean
@@ -117,8 +118,13 @@ export default function ReceptionProgressModal({
const data = detailQuery.data
const reception = data?.reception || data
const progressStatuses: string[] = data?.progressStatuses || []
const giftStatuses: string[] = data?.giftStatuses || []
const progressStatuses: string[] = data?.progressStatuses || Object.keys(PROGRESS_STATUS)
const giftStatuses: string[] = data?.giftStatuses || Object.keys(GIFT_STATUS)
const progressLabels = (data?.progressStatusLabels || data?.statusOptions || PROGRESS_STATUS) as Record<string, string>
const giftLabels = (data?.giftStatusLabels || data?.giftStatusOptions || GIFT_STATUS) as Record<string, string>
const allowed: string[] = data?.allowedProgressStatuses || []
const progressOpts = statusOptions(progressLabels, allowed.length ? allowed : undefined)
const giftOpts = statusOptions(giftLabels)
const progressLogs: any[] = data?.progressLogs || data?.logs || []
const giftLogs: any[] = data?.giftProgressLogs || []
@@ -234,7 +240,7 @@ export default function ReceptionProgressModal({
}
}
const titleWarn = reception?.progressStatus === '9.취소' || reception?.detailProgress?.includes?.('주의')
const titleWarn = reception?.progressStatus === 'f11' || reception?.progressStatus === '9.취소' || reception?.detailProgress?.includes?.('주의')
const title = receptionId
? `${titleWarn ? '⚠ 주의 요망 ' : ''}업무 진행 내역 — 접수 #${receptionId}`
: '업무 진행 내역'
@@ -276,7 +282,7 @@ export default function ReceptionProgressModal({
<tr>
<th>현재진행상황</th>
<td colSpan={3}>
<Input readOnly value={reception?.progressStatus || ''} style={{ width: 200 }} />
<Input readOnly value={progressLabel(reception?.progressStatus) || reception?.progressStatus || ''} style={{ width: 200 }} />
</td>
</tr>
<tr>
@@ -301,7 +307,7 @@ export default function ReceptionProgressModal({
style={{ minWidth: 180 }}
value={f.status || undefined}
onChange={(v) => set({ status: v || '' })}
options={progressStatuses.map((s) => ({ value: s, label: s }))}
options={progressOpts.length ? progressOpts : progressStatuses.map((s) => ({ value: s, label: progressLabel(s) || s }))}
/>
<Button size="small" loading={saveMut.isPending} onClick={() => saveMut.mutate(false)}>
적 용
@@ -433,7 +439,7 @@ export default function ReceptionProgressModal({
<tr>
<th>사은품현재상황</th>
<td>
<Input readOnly value={data.gift?.status || reception?.giftStatus || ''} />
<Input readOnly value={giftLabel(data.gift?.status || reception?.giftStatus) || data.gift?.status || reception?.giftStatus || ''} />
</td>
<th>사은품진행상황</th>
<td>
@@ -443,7 +449,7 @@ export default function ReceptionProgressModal({
style={{ width: '100%' }}
value={f.giftStatus || undefined}
onChange={(v) => set({ giftStatus: v || '' })}
options={giftStatuses.map((s) => ({ value: s, label: s }))}
options={giftOpts.length ? giftOpts : giftStatuses.map((s) => ({ value: s, label: giftLabel(s) || s }))}
/>
</td>
</tr>
@@ -455,12 +461,7 @@ export default function ReceptionProgressModal({
style={{ width: '100%' }}
value={f.giftPlace || undefined}
onChange={(v) => set({ giftPlace: v || '' })}
options={[
{ value: 'b01', label: '본사요청' },
{ value: 'b02', label: '자체발송' },
{ value: '본사', label: '본사' },
{ value: '영업점', label: '영업점' },
]}
options={Object.entries(GIFT_PLACE).map(([value, label]) => ({ value, label }))}
/>
</td>
<th>지급요청/완료</th>
+38 -3
View File
@@ -44,10 +44,25 @@ export function careMenuSections(): MenuSection[] {
{ key: '/care/reception/list', label: '접수리스트' },
],
},
{
id: 'branch',
title: '영업점관리',
items: [
{ key: '/care/branch/list', label: '영업점목록' },
],
},
{
id: 'invoice',
title: '정산관리',
items: [
{ key: '/care/calculate/branch?tab=prepared', label: '가정산 리스트' },
{ key: '/care/calculate/branch?tab=waiting', label: '정산대기 리스트' },
{ key: '/care/calculate/branch?tab=completed', label: '정산완료 리스트' },
{ key: '/care/calculate/branch?tab=transferred', label: '송금(환수)완료 리스트' },
{ key: '/care/calculate/center?tab=prepared', label: '입력처 가정산 리스트' },
{ key: '/care/calculate/center?tab=waiting', label: '입력처 정산대기 리스트' },
{ key: '/care/calculate/center?tab=completed', label: '입력처 정산완료 리스트' },
{ key: '/care/calculate/center?tab=transferred', label: '입금완료 리스트' },
{ key: '/care/invoice/publish', label: '정산서발행' },
{ key: '/care/invoice/my', label: '나의정산서' },
{ key: '/care/invoice/log', label: '정산서이력' },
@@ -109,6 +124,14 @@ export function careMenuSections(): MenuSection[] {
{ key: '/care/setting/level', label: '권한설정' },
{ key: '/care/setting/partner1', label: '상부점관리' },
{ key: '/care/setting/partner2', label: '하부점관리' },
{ key: '/care/setting/isp', label: '통신사관리' },
{ key: '/care/setting/center', label: '입력처관리' },
{ key: '/care/setting/isp-link', label: '통신사인증링크' },
{ key: '/care/setting/board-admin', label: '게시판관리' },
{ key: '/care/setting/fee', label: '수수료관리' },
{ key: '/care/setting/quick-delivery', label: '퀵전송 연동' },
{ key: '/care/setting/configure', label: '기타 환경 설정' },
{ key: '/care/setting/usage', label: '이용현황' },
],
},
{
@@ -162,6 +185,7 @@ export const SITEMAP_TITLE_TO_ID: Record<string, string> = {
상품관리: 'product',
계약관리: 'contract',
접수관리: 'reception',
영업점관리: 'branch',
정산관리: 'invoice',
일정관리: 'pending',
예약관리: 'booking',
@@ -174,17 +198,28 @@ export const SITEMAP_TITLE_TO_ID: Record<string, string> = {
게시판: 'bbs',
}
export function resolveSelectedKey(pathname: string): string {
export function resolveSelectedKey(pathname: string, search?: string): string {
if (pathname.startsWith('/care/contract/write')) {
const q = typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('type') : null
const q = new URLSearchParams(search ?? (typeof window !== 'undefined' ? window.location.search : '')).get('type')
if (q === 'home') return '/care/contract/home'
return '/care/contract/internet'
}
if (pathname === '/care/sitemap') return '/care/main/sitemap'
if (pathname.startsWith('/care/calculate/')) {
const tab = new URLSearchParams(search ?? (typeof window !== 'undefined' ? window.location.search : '')).get('tab') || 'prepared'
return `${pathname}?tab=${tab}`
}
return pathname
}
export function sectionContainsPath(section: MenuSection, pathname: string): boolean {
const selected = resolveSelectedKey(pathname)
return section.items.some((it) => selected === it.key || selected.startsWith(`${it.key}/`))
return section.items.some((it) => {
if (selected === it.key) return true
if (selected.startsWith(`${it.key}/`)) return true
// 정산 메뉴: pathname 만으로도 섹션 활성
const itPath = it.key.split('?')[0]
if (pathname === itPath || pathname.startsWith(`${itPath}/`)) return true
return false
})
}
+1 -1
View File
@@ -592,7 +592,7 @@ export default function AbooksPage() {
</Form.Item>
<div className="balance-modal-footer">
<Button className="abook-submit-btn" type="primary" htmlType="submit" loading={save.isPending}>
{editing ? '수정합니다' : '등록'}
{editing ? '수정' : '등록'}
</Button>
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
</div>
+1 -1
View File
@@ -572,7 +572,7 @@ export default function BookingPage() {
<div className="booking-modal-note">* 고객명, 연락처는 고객관리에도 동시등록됩니다.</div>
<Space>
<Button className="booking-submit-btn" type="primary" htmlType="submit" loading={save.isPending}>
{editing ? '수정합니다' : '등록'}
{editing ? '수정' : '등록'}
</Button>
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
</Space>
@@ -0,0 +1,531 @@
import { useEffect, useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Button,
DatePicker,
Form,
Input,
InputNumber,
Select,
Space,
Table,
Typography,
message,
} from 'antd'
import dayjs from 'dayjs'
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
import DraggableModal from '../../components/DraggableModal'
import { FormField, FormGrid, FormSection } from '../../components/OrderFormLayout'
export type FeeEntry = {
id: number
type: string
ispName?: string
carrier?: string
category?: string
startDate?: string
}
type FeeProduct = {
id?: number
productType: string
productName: string
fee: number
}
type FeeDetail = {
fee: {
id: number
type: string
carrier?: string
ispName?: string
startDate?: string
memo?: string
source?: string
file1?: string
file2?: string
file3?: string
file4?: string
file5?: string
branchName?: string
}
products: FeeProduct[]
}
const PRODUCT_TYPE: Record<string, string> = {
e01: '인터넷',
e02: '전화',
e03: 'TV',
e04: '기타',
}
const CARRIERS = ['KT', 'SKB', 'LGU+', 'SKT', '기타']
const won = (n: number) => `${(n ?? 0).toLocaleString('ko-KR')} 원`
type Props = {
open: boolean
branchId: number | null
branchName: string
onClose: () => void
}
/** 영업점 수수료 목록 → 상세보기(개별/그룹) → 저장 — billing_imeere branchFees 흐름 */
export default function BranchFeeFlowModals({ open, branchId, branchName, onClose }: Props) {
const qc = useQueryClient()
const [selectedFeeKey, setSelectedFeeKey] = useState<string | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const [createOpen, setCreateOpen] = useState(false)
const [detailType, setDetailType] = useState<'alone' | 'group'>('alone')
const [detailFeeId, setDetailFeeId] = useState<number | null>(null)
const [editable, setEditable] = useState(false)
const [memo, setMemo] = useState('')
const [startDate, setStartDate] = useState<string>('')
const [products, setProducts] = useState<FeeProduct[]>([])
const [selectedProductKey, setSelectedProductKey] = useState<string | null>(null)
const [createForm] = Form.useForm()
const { data: entries = [], isLoading } = useQuery({
queryKey: ['homes-branch-fees', branchId],
enabled: !!branchId && open,
queryFn: async () => {
const { data } = await client.get<ApiResponse<FeeEntry[]>>(
`${apiPrefix()}/homes/branches/${branchId}/fees`,
)
return unwrap(data) || []
},
})
const { data: detail, isLoading: detailLoading } = useQuery({
queryKey: ['homes-branch-fee-detail', detailFeeId, detailType],
enabled: !!detailFeeId && detailOpen,
queryFn: async () => {
const { data } = await client.get<ApiResponse<FeeDetail>>(
`${apiPrefix()}/homes/branch-fees/${detailFeeId}`,
{ params: { type: detailType } },
)
return unwrap(data)
},
})
const { data: templates = [] } = useQuery({
queryKey: ['homes-branch-fee-templates'],
enabled: createOpen,
queryFn: async () => {
const { data } = await client.get<ApiResponse<FeeProduct[]>>(
`${apiPrefix()}/homes/branch-fee-product-templates`,
)
return unwrap(data) || []
},
})
useEffect(() => {
if (!detail) return
setMemo(detail.fee.memo || '')
setStartDate(detail.fee.startDate || '')
setProducts((detail.products || []).map((p) => ({ ...p, fee: p.fee ?? 0 })))
setEditable(detail.fee.type === 'alone')
}, [detail])
useEffect(() => {
if (!createOpen) return
const rows = templates.length
? templates.map((t) => ({ ...t, fee: 0 }))
: [{ productType: 'e01', productName: '기가인터넷 1G', fee: 0 }]
createForm.setFieldsValue({
carrier: createForm.getFieldValue('carrier') || 'KT',
startDate: createForm.getFieldValue('startDate') || dayjs().startOf('day'),
products: rows,
})
}, [createOpen, templates, createForm])
const deleteMut = useMutation({
mutationFn: async (feeId: number) => {
const { data } = await client.delete(`${apiPrefix()}/homes/branch-fees/${feeId}`)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success('삭제했습니다.')
qc.invalidateQueries({ queryKey: ['homes-branch-fees', branchId] })
},
onError: (e: Error) => message.error(e.message),
})
const saveMut = useMutation({
mutationFn: async () => {
const { data } = await client.put(
`${apiPrefix()}/homes/branch-fees/${detailFeeId}`,
{
startDate,
memo,
carrier: detail?.fee.carrier || detail?.fee.ispName,
products: products.map((p) => ({
productType: p.productType,
productName: p.productName,
fee: p.fee ?? 0,
})),
},
)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success('수정되었습니다.')
qc.invalidateQueries({ queryKey: ['homes-branch-fee-detail', detailFeeId, detailType] })
qc.invalidateQueries({ queryKey: ['homes-branch-fees', branchId] })
},
onError: (e: Error) => message.error(e.message),
})
const createMut = useMutation({
mutationFn: async (values: Record<string, unknown>) => {
const productRows: FeeProduct[] = (values.products as FeeProduct[]) || templates
const sd = values.startDate
const startDateStr = sd && typeof sd === 'object' && 'format' in (sd as object)
? (sd as { format: (f: string) => string }).format('YYYY-MM-DD HH:mm:ss')
: (sd ? dayjs(String(sd)).format('YYYY-MM-DD HH:mm:ss') : dayjs().format('YYYY-MM-DD 00:00:00'))
const { data } = await client.post(
`${apiPrefix()}/homes/branches/${branchId}/fees`,
{
carrier: values.carrier,
startDate: startDateStr,
memo: values.memo || '',
products: productRows.map((p) => ({
productType: p.productType,
productName: p.productName,
fee: Number(p.fee) || 0,
})),
},
)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success('등록되었습니다.')
setCreateOpen(false)
createForm.resetFields()
qc.invalidateQueries({ queryKey: ['homes-branch-fees', branchId] })
},
onError: (e: Error) => message.error(e.message),
})
const openDetail = (row: FeeEntry, editMode: boolean) => {
setDetailType(row.type === 'group' ? 'group' : 'alone')
setDetailFeeId(row.id)
setDetailOpen(true)
setEditable(row.type === 'alone' && editMode)
// if open via 상세보기 for alone, still allow save (screenshot has 저장)
if (row.type === 'alone') setEditable(true)
if (row.type === 'group') setEditable(false)
}
const backToList = () => {
setDetailOpen(false)
setDetailFeeId(null)
}
const titleKind = detailType === 'group' ? '그룹' : '개별'
const files = useMemo(() => ([
detail?.fee.file1, detail?.fee.file2, detail?.fee.file3, detail?.fee.file4, detail?.fee.file5,
]), [detail])
return (
<>
<DraggableModal
title={`영업점 수수료 - ${branchName}`}
open={open && !detailOpen && !createOpen}
onCancel={onClose}
width={780}
destroyOnClose={false}
footer={(
<Space>
<Button type="primary" onClick={() => {
createForm.resetFields()
createForm.setFieldsValue({
carrier: 'KT',
startDate: dayjs().startOf('day'),
memo: '',
})
setCreateOpen(true)
}}
>
등록
</Button>
<Button onClick={onClose}>목록</Button>
</Space>
)}
>
<Table
size="small"
rowKey={(r) => `${r.type}-${r.id}`}
loading={isLoading}
dataSource={entries}
pagination={{ pageSize: 20, showSizeChanger: false }}
locale={{ emptyText: '등록된 수수료가 없습니다.' }}
onRow={(r) => ({
onClick: () => setSelectedFeeKey(`${r.type}-${r.id}`),
className: selectedFeeKey === `${r.type}-${r.id}` ? 'row-selected' : undefined,
})}
columns={[
{
title: '판매처',
dataIndex: 'ispName',
render: (v, r) => v || r.carrier || '',
},
{
title: '적용일',
dataIndex: 'startDate',
align: 'center',
width: 170,
},
{
title: '분류',
dataIndex: 'category',
align: 'center',
width: 80,
render: (v, r) => v || (r.type === 'group' ? '그룹' : '공통'),
},
{
title: '상세보기',
width: 90,
align: 'center',
render: (_, r) => (
<Button
size="small"
type="primary"
className="branch-row-btn"
onClick={(e) => { e.stopPropagation(); openDetail(r, false) }}
>
상세보기
</Button>
),
},
{
title: '수정',
width: 90,
align: 'center',
render: (_, r) => (r.type === 'alone'
? (
<Button
size="small"
type="primary"
className="branch-row-btn"
onClick={(e) => { e.stopPropagation(); openDetail(r, true) }}
>
수정하기
</Button>
)
: '-'),
},
{
title: '삭제',
width: 80,
align: 'center',
render: (_, r) => (r.type === 'alone'
? (
<Button
size="small"
type="primary"
danger
className="branch-row-btn"
onClick={(e) => {
e.stopPropagation()
if (window.confirm('정말로 삭제하시겠습니까?')) deleteMut.mutate(r.id)
}}
>
삭제
</Button>
)
: '-'),
},
]}
/>
</DraggableModal>
<DraggableModal
title={`영업점 ${titleKind} 수수료 - ${branchName}`}
open={detailOpen}
onCancel={backToList}
width={800}
destroyOnClose
className="order-form-modal branch-form-modal"
styles={{ body: { maxHeight: 'calc(92vh - 56px)', overflowY: 'auto' } }}
footer={(
<Space>
<Button onClick={backToList}>목록으로</Button>
{editable && (
<Button type="primary" loading={saveMut.isPending} onClick={() => saveMut.mutate()}>
저장
</Button>
)}
</Space>
)}
>
{detailLoading || !detail ? (
<Typography.Text type="secondary">불러오는 중...</Typography.Text>
) : (
<div className="order-form-embedded-inner order-form-page reception-apply-form branch-fee-detail">
<FormSection title="기본 정보">
<FormGrid>
<FormField label="출처">
<span>{detail.fee.source || detail.fee.id}</span>
</FormField>
<FormField label="판매처">
<span>{detail.fee.carrier || detail.fee.ispName || '-'}</span>
</FormField>
<FormField label="적용일자" full>
{editable ? (
<DatePicker
showTime
value={startDate ? dayjs(startDate) : null}
onChange={(d) => setStartDate(d ? d.format('YYYY-MM-DD HH:mm:ss') : '')}
style={{ width: '100%' }}
/>
) : (detail.fee.startDate || '-')}
</FormField>
</FormGrid>
</FormSection>
<FormSection title="상품별 수수료">
<Table
size="small"
pagination={false}
rowKey={(r) => `${r.productType}-${r.productName}`}
dataSource={products}
locale={{ emptyText: '상품별 수수료가 없습니다.' }}
onRow={(r) => ({
onClick: () => setSelectedProductKey(`${r.productType}-${r.productName}`),
className: selectedProductKey === `${r.productType}-${r.productName}` ? 'row-selected' : undefined,
})}
columns={[
{
title: '상품유형',
dataIndex: 'productType',
width: 100,
align: 'center',
render: (t: string) => PRODUCT_TYPE[t] || t || '기타',
},
{ title: '상품명', dataIndex: 'productName' },
{
title: '적용수수료',
dataIndex: 'fee',
width: 140,
align: 'right',
render: (fee: number, _r, idx) => (editable ? (
<InputNumber
min={0}
step={1000}
value={fee}
style={{ width: 120 }}
onChange={(v) => {
setProducts((prev) => prev.map((p, i) => (i === idx ? { ...p, fee: Number(v) || 0 } : p)))
}}
/>
) : won(fee)),
},
]}
/>
</FormSection>
<FormSection title="기타">
<FormGrid cols={1}>
<FormField label="메모" full>
{editable ? (
<Input.TextArea rows={2} value={memo} onChange={(e) => setMemo(e.target.value)} />
) : (memo || '')}
</FormField>
{[1, 2, 3, 4, 5].map((n) => (
<FormField key={n} label={`파일${n}`} full>
<span>{files[n - 1] || ''}</span>
</FormField>
))}
</FormGrid>
</FormSection>
</div>
)}
</DraggableModal>
<DraggableModal
title={`영업점 개별 수수료 등록 - ${branchName}`}
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={() => createForm.submit()}
confirmLoading={createMut.isPending}
okText="등록"
width={800}
destroyOnClose
className="order-form-modal branch-form-modal"
styles={{ body: { maxHeight: 'calc(92vh - 56px)', overflowY: 'auto' } }}
>
<Form
form={createForm}
layout="vertical"
className="order-form-embedded-inner order-form-page reception-apply-form"
onFinish={(v) => createMut.mutate(v)}
initialValues={{
carrier: 'KT',
startDate: dayjs().startOf('day'),
}}
>
<FormSection title="기본 정보">
<FormGrid>
<FormField label="판매처(통신사)*">
<Form.Item name="carrier" noStyle rules={[{ required: true, message: '통신사를 선택하세요' }]}>
<Select options={CARRIERS.map((c) => ({ value: c, label: c }))} />
</Form.Item>
</FormField>
<FormField label="적용일*">
<Form.Item name="startDate" noStyle rules={[{ required: true, message: '적용일을 입력하세요' }]}>
<DatePicker showTime style={{ width: '100%' }} />
</Form.Item>
</FormField>
<FormField label="메모" full>
<Form.Item name="memo" noStyle><Input.TextArea rows={2} /></Form.Item>
</FormField>
</FormGrid>
</FormSection>
<FormSection title="상품별 수수료">
<Form.List name="products">
{(fields) => (
<Table
size="small"
pagination={false}
dataSource={fields.map((f) => ({ ...f, key: f.key }))}
columns={[
{
title: '상품유형',
width: 120,
render: (_, field) => (
<Form.Item name={[field.name, 'productType']} noStyle>
<Select
options={Object.entries(PRODUCT_TYPE).map(([k, v]) => ({ value: k, label: v }))}
style={{ width: 100 }}
/>
</Form.Item>
),
},
{
title: '상품명',
render: (_, field) => (
<Form.Item name={[field.name, 'productName']} noStyle>
<Input />
</Form.Item>
),
},
{
title: '적용수수료',
width: 130,
render: (_, field) => (
<Form.Item name={[field.name, 'fee']} noStyle>
<InputNumber min={0} style={{ width: 120 }} />
</Form.Item>
),
},
]}
/>
)}
</Form.List>
</FormSection>
</Form>
</DraggableModal>
</>
)
}
+788
View File
@@ -0,0 +1,788 @@
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Button,
Card,
Checkbox,
Form,
Input,
Modal,
Radio,
Select,
Space,
Table,
Typography,
message,
} from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { PlusOutlined, SearchOutlined, ShopOutlined } from '@ant-design/icons'
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
import DraggableModal from '../../components/DraggableModal'
import { FormField, FormGrid, FormSection } from '../../components/OrderFormLayout'
import BranchFeeFlowModals from './BranchFeeFlowModals'
type BranchRow = {
id: number
name?: string
officeName?: string
userid?: string
phone?: string
email?: string
sido?: string
level?: number
grant?: number
branchGroupId?: number | null
branchGroupName?: string | null
hasBusinessLicense?: boolean
isTax?: number
createdAt?: string
}
type BranchGroup = { id: number; name: string }
type BranchDetail = BranchRow & {
fax?: string
zipcode?: string
address?: string
addressDetail?: string
tel?: string
centerName?: string
isPersonal?: boolean
accountBank?: string
accountNumber?: string
accountName?: string
contactPhone?: string
contactNateon?: string
acceptPhone?: number
acceptNateon?: number
memo?: string
adminMemo?: string
licenseNumber?: string
licenseType?: string
licenseItem?: string
notifyPhones?: string[]
permissionOrderDateUpdate?: boolean
permissionCalculate?: boolean
permissionConfig?: boolean
permissionExcelDownload?: boolean
permissionChart?: boolean
}
const PAGE_SIZE = 50
const BANKS = ['국민은행', '우리은행', '신한은행', '하나은행', '기업은행', '농협', 'SC제일', '카카오뱅크', '기타']
function levelLabel(l?: number) {
if (l === 5) return <span style={{ color: '#04A600' }}>센터</span>
if (l === 9) return <span style={{ color: '#0F6DC4' }}>관리자</span>
if (l === 10) return <span style={{ color: '#cc0000' }}>최고관리자</span>
return <span>영업점</span>
}
function EditBranchFormBody({
groups,
centers,
}: {
groups: BranchGroup[]
centers: { id: number; name: string }[]
}) {
const isPersonal = Form.useWatch('isPersonal')
const level = Form.useWatch('level')
return (
<>
<FormSection title="기본 정보">
<FormGrid cols={1}>
<FormField label="유형" full>
<Form.Item name="isPersonal" noStyle>
<Radio.Group
className="of-antd-choice-row"
style={{ padding: 0, border: 'none' }}
options={[
{ value: true, label: '개인' },
{ value: false, label: '사업자 (일반/법인)' },
]}
/>
</Form.Item>
</FormField>
</FormGrid>
<FormGrid>
<FormField label="영업점 그룹">
<Form.Item name="branchGroupId" noStyle>
<Select allowClear placeholder="영업점 그룹 선택" options={groups.map((g) => ({ value: g.id, label: g.name }))} />
</Form.Item>
</FormField>
<FormField label="수수료 유지">
<Form.Item name="keepFee" valuePropName="checked" noStyle>
<Checkbox>그룹 변경 시 수수료 유지</Checkbox>
</Form.Item>
</FormField>
<FormField label="레벨">
<Form.Item name="level" noStyle>
<Select options={[
{ value: 1, label: '영업점' },
{ value: 5, label: '센터' },
{ value: 9, label: '관리자' },
{ value: 10, label: '최고관리자' },
]} />
</Form.Item>
</FormField>
<FormField label="입력처">
{level === 5 ? (
<Form.Item name="centerName" noStyle>
<Select allowClear options={centers.map((c) => ({ value: c.name, label: c.name }))} />
</Form.Item>
) : (
<span className="of-muted">센터 레벨에서 선택</span>
)}
</FormField>
{level === 9 && (
<FormField label="권한" full>
<Space wrap size={[8, 4]}>
<Form.Item name="permissionOrderDateUpdate" valuePropName="checked" noStyle><Checkbox>접수일자 변경</Checkbox></Form.Item>
<Form.Item name="permissionCalculate" valuePropName="checked" noStyle><Checkbox>정산</Checkbox></Form.Item>
<Form.Item name="permissionConfig" valuePropName="checked" noStyle><Checkbox>환경설정</Checkbox></Form.Item>
<Form.Item name="permissionExcelDownload" valuePropName="checked" noStyle><Checkbox>엑셀 다운로드</Checkbox></Form.Item>
<Form.Item name="permissionChart" valuePropName="checked" noStyle><Checkbox>통계</Checkbox></Form.Item>
</Space>
</FormField>
)}
<FormField label="이름*">
<Form.Item name="name" noStyle rules={[{ required: true }]}><Input /></Form.Item>
</FormField>
<FormField label="영업점명*">
<Form.Item name="officeName" noStyle rules={[{ required: true }]}><Input /></Form.Item>
</FormField>
<FormField label="아이디">
<Form.Item name="userid" noStyle><Input disabled /></Form.Item>
</FormField>
<FormField label="패스워드">
<div style={{ display: 'flex', gap: 8, width: '100%' }}>
<Form.Item name="password" noStyle style={{ flex: 1, margin: 0 }}>
<Input.Password placeholder="변경 시 입력" />
</Form.Item>
<Form.Item name="passwordConfirmation" noStyle style={{ flex: 1, margin: 0 }}>
<Input.Password placeholder="확인" />
</Form.Item>
</div>
</FormField>
<FormField label="이메일" full>
<Form.Item name="email" noStyle><Input /></Form.Item>
</FormField>
<FormField label="휴대폰">
<Form.Item name="phone" noStyle><Input placeholder="010-0000-0000" /></Form.Item>
</FormField>
<FormField label="연락처 / 팩스">
<div style={{ display: 'flex', gap: 8, width: '100%' }}>
<Form.Item name="tel" noStyle style={{ flex: 1, margin: 0 }}><Input placeholder="전화" /></Form.Item>
<Form.Item name="fax" noStyle style={{ flex: 1, margin: 0 }}><Input placeholder="팩스" /></Form.Item>
</div>
</FormField>
<FormField label="우편번호">
<Form.Item name="zipcode" noStyle><Input /></Form.Item>
</FormField>
<FormField label="주소">
<Form.Item name="address" noStyle><Input /></Form.Item>
</FormField>
<FormField label="상세주소" full>
<Form.Item name="addressDetail" noStyle><Input /></Form.Item>
</FormField>
{isPersonal === false && (
<>
<FormField label="사업자등록번호">
<Form.Item name="licenseNumber" noStyle><Input /></Form.Item>
</FormField>
<FormField label="업태 / 업종">
<div style={{ display: 'flex', gap: 8, width: '100%' }}>
<Form.Item name="licenseType" noStyle style={{ flex: 1, margin: 0 }}><Input placeholder="업태" /></Form.Item>
<Form.Item name="licenseItem" noStyle style={{ flex: 1, margin: 0 }}><Input placeholder="업종" /></Form.Item>
</div>
</FormField>
</>
)}
</FormGrid>
</FormSection>
<FormSection title="결제 정보">
<FormGrid>
<FormField label="은행">
<Form.Item name="accountBank" noStyle>
<Select allowClear options={BANKS.map((b) => ({ value: b, label: b }))} />
</Form.Item>
</FormField>
<FormField label="계좌번호">
<Form.Item name="accountNumber" noStyle><Input /></Form.Item>
</FormField>
<FormField label="예금주">
<div style={{ display: 'flex', gap: 8, width: '100%', alignItems: 'center' }}>
<Form.Item name="accountName" noStyle style={{ flex: 1, margin: 0 }}><Input /></Form.Item>
<Form.Item name="accountNameIsName" valuePropName="checked" noStyle>
<Checkbox>정보동일</Checkbox>
</Form.Item>
</div>
</FormField>
<FormField label="세금계산서">
<Form.Item name="isTax" noStyle>
<Select options={[
{ value: 1, label: '세금계산서발행' },
{ value: 0, label: '종합소득세공제' },
]} />
</Form.Item>
</FormField>
</FormGrid>
</FormSection>
<FormSection title="업무진행 상황 수신정보">
<FormGrid>
<FormField label="수신설정" full>
<Space wrap>
<Form.Item name="acceptPhone" valuePropName="checked" noStyle><Checkbox>핸드폰 문자 받음</Checkbox></Form.Item>
<Form.Item name="acceptNateon" valuePropName="checked" noStyle><Checkbox>네이트온 쪽지 받음</Checkbox></Form.Item>
</Space>
</FormField>
<FormField label="휴대폰">
<Form.Item name="contactPhone" noStyle><Input /></Form.Item>
</FormField>
<FormField label="네이트온 아이디">
<Form.Item name="contactNateon" noStyle><Input /></Form.Item>
</FormField>
<FormField label="정책 수신 휴대폰" full>
<Form.List name="notifyPhones">
{(fields) => (
<Space wrap>
{fields.map((field) => (
<Form.Item {...field} key={field.key} noStyle>
<Input style={{ width: 140 }} placeholder={`휴대폰 ${field.name + 1}`} />
</Form.Item>
))}
</Space>
)}
</Form.List>
</FormField>
</FormGrid>
</FormSection>
<FormSection title="메모">
<FormGrid cols={1}>
<FormField label="메모" full>
<Form.Item name="memo" noStyle><Input.TextArea rows={2} /></Form.Item>
</FormField>
<FormField label="관리자 메모" full>
<Form.Item name="adminMemo" noStyle><Input.TextArea rows={2} /></Form.Item>
</FormField>
</FormGrid>
</FormSection>
</>
)
}
export default function BranchListPage() {
const qc = useQueryClient()
const [form] = Form.useForm()
const [editForm] = Form.useForm()
const [createOpen, setCreateOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [feeOpen, setFeeOpen] = useState(false)
const [feeBranch, setFeeBranch] = useState<BranchRow | null>(null)
const [editingId, setEditingId] = useState<number | null>(null)
const [selectedId, setSelectedId] = useState<number | null>(null)
const [page, setPage] = useState(0)
const [draft, setDraft] = useState({ fGroup: '', fSido: '', fUserid: '', fOffice: '', fName: '' })
const [applied, setApplied] = useState(draft)
const { data: rows = [], isLoading } = useQuery({
queryKey: ['homes-branches'],
queryFn: async () => {
const { data } = await client.get<ApiResponse<BranchRow[]>>(`${apiPrefix()}/homes/branches`)
return unwrap(data) || []
},
})
const { data: groups = [] } = useQuery({
queryKey: ['homes-branch-groups'],
queryFn: async () => {
const { data } = await client.get<ApiResponse<BranchGroup[]>>(`${apiPrefix()}/homes/branch-groups`)
return unwrap(data) || []
},
})
const { data: centers = [] } = useQuery({
queryKey: ['homes-branch-centers'],
queryFn: async () => {
const { data } = await client.get<ApiResponse<{ id: number; name: string }[]>>(`${apiPrefix()}/homes/branch-centers`)
return unwrap(data) || []
},
})
const sidoOptions = useMemo(() => {
const set = new Set<string>()
rows.forEach((b) => { if (b.sido) set.add(b.sido) })
return Array.from(set).sort()
}, [rows])
const filtered = useMemo(() => {
const inc = (v: string | null | undefined, q: string) => (v ?? '').toLowerCase().includes(q.toLowerCase())
return rows.filter((b) =>
(!applied.fGroup || String(b.branchGroupId ?? '') === applied.fGroup)
&& (!applied.fSido || b.sido === applied.fSido)
&& (!applied.fUserid || inc(b.userid, applied.fUserid))
&& (!applied.fOffice || inc(b.officeName, applied.fOffice))
&& (!applied.fName || inc(b.name, applied.fName)),
)
}, [rows, applied])
const pageRows = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
const grantMut = useMutation({
mutationFn: async ({ id, g }: { id: number; g: number }) => {
const { data } = await client.post(`${apiPrefix()}/homes/branches/${id}/grant/${g}`)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['homes-branches'] })
message.success('승인 상태를 변경했습니다.')
},
onError: (e: Error) => message.error(e.message),
})
const createMut = useMutation({
mutationFn: async (values: Record<string, unknown>) => {
const params = new URLSearchParams({ level: String(values.level ?? 1) })
if (values.branchGroupId) params.set('branchGroupId', String(values.branchGroupId))
const { data } = await client.post(
`${apiPrefix()}/homes/branches?${params.toString()}`,
values,
)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success('영업점을 등록했습니다.')
setCreateOpen(false)
form.resetFields()
qc.invalidateQueries({ queryKey: ['homes-branches'] })
},
onError: (e: Error) => message.error(e.message),
})
const updateMut = useMutation({
mutationFn: async (values: Record<string, unknown>) => {
const { data } = await client.put(
`${apiPrefix()}/homes/branches/${editingId}/detail`,
values,
)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success('수정했습니다.')
setEditOpen(false)
setEditingId(null)
qc.invalidateQueries({ queryKey: ['homes-branches'] })
},
onError: (e: Error) => message.error(e.message),
})
const deleteMut = useMutation({
mutationFn: async (id: number) => {
const { data } = await client.delete(`${apiPrefix()}/homes/branches/${id}`)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success('삭제했습니다.')
qc.invalidateQueries({ queryKey: ['homes-branches'] })
},
onError: (e: Error) => message.error(e.message),
})
const openEdit = async (row: BranchRow) => {
try {
const { data } = await client.get<ApiResponse<BranchDetail>>(
`${apiPrefix()}/homes/branches/${row.id}/detail`,
)
const detail = unwrap(data)
setEditingId(row.id)
editForm.setFieldsValue({
...detail,
isPersonal: detail.isPersonal !== false,
notifyPhones: [...(detail.notifyPhones || []), '', '', '', '', '', ''].slice(0, 6),
keepFee: false,
password: '',
passwordConfirmation: '',
accountNameIsName: false,
acceptPhone: detail.acceptPhone === 1,
acceptNateon: detail.acceptNateon === 1,
})
setEditOpen(true)
} catch (e) {
message.error((e as Error).message)
}
}
const confirmDelete = (row: BranchRow) => {
Modal.confirm({
title: '영업점 삭제',
content: (
<div>
<p><b>{row.officeName}</b> 영업점을 삭제하시겠습니까?</p>
<p className="muted">관련 접수·정산 자료는 삭제되지 않지만, 영업점 계정 정보는 제거됩니다.</p>
</div>
),
okText: '삭제',
okButtonProps: { danger: true },
cancelText: '취소',
onOk: () => deleteMut.mutateAsync(row.id),
})
}
const columns: ColumnsType<BranchRow> = [
{ title: '그룹', dataIndex: 'branchGroupName', width: 110, ellipsis: true },
{ title: '지역', dataIndex: 'sido', width: 70, align: 'center' },
{ title: '아이디', dataIndex: 'userid', width: 100, ellipsis: true },
{ title: '영업점명', dataIndex: 'officeName', width: 120, ellipsis: true },
{ title: '이름', dataIndex: 'name', width: 90, ellipsis: true },
{
title: '레벨',
dataIndex: 'level',
width: 80,
align: 'center',
render: (v) => levelLabel(v),
},
{ title: '연락처', dataIndex: 'phone', width: 120 },
{
title: '사업자',
dataIndex: 'hasBusinessLicense',
width: 70,
align: 'center',
render: (v) => (v ? '사업자' : '개인'),
},
{
title: '세금계산서',
dataIndex: 'isTax',
width: 110,
align: 'center',
render: (v) => (v === 1 ? '세금계산서발행' : '종합소득세공제'),
},
{
title: '회원가입일',
dataIndex: 'createdAt',
width: 150,
align: 'center',
render: (v?: string) => v?.replace('T', ' ').substring(0, 19),
},
{
title: '수수료',
width: 80,
align: 'center',
render: (_, r) => (
<Button
size="small"
type="primary"
className="branch-row-btn"
onClick={(e) => {
e.stopPropagation()
setFeeBranch(r)
setFeeOpen(true)
}}
>
수수료
</Button>
),
},
{
title: '승인여부',
width: 80,
align: 'center',
render: (_, r) => (
r.grant === 1
? (
<Button
size="small"
type="primary"
className="branch-row-btn"
onClick={(e) => {
e.stopPropagation()
grantMut.mutate({ id: r.id, g: 0 })
}}
>
승인
</Button>
)
: (
<Button
size="small"
type="primary"
danger
className="branch-row-btn"
onClick={(e) => {
e.stopPropagation()
grantMut.mutate({ id: r.id, g: 1 })
}}
>
거부
</Button>
)
),
},
{
title: '수정',
width: 70,
align: 'center',
render: (_, r) => (
<Button
size="small"
type="primary"
className="branch-row-btn"
onClick={(e) => { e.stopPropagation(); void openEdit(r) }}
>
수정
</Button>
),
},
{
title: '삭제',
width: 70,
align: 'center',
render: (_, r) => (
<Button
size="small"
type="primary"
danger
className="branch-row-btn"
onClick={(e) => { e.stopPropagation(); confirmDelete(r) }}
>
삭제
</Button>
),
},
]
return (
<div className="stock-page branch-list-page">
<div className="stock-title">
<div>
<h2>
<ShopOutlined className="product-title-icon" />
영업점 목록
</h2>
<p>영업점을 검색·등록하고 승인·수수료·상세정보를 관리합니다.</p>
</div>
<Button type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); form.setFieldsValue({ level: 1, isTax: 1 }); setCreateOpen(true) }}>
등록
</Button>
</div>
<Card className="stock-table-card" size="small">
<Space wrap style={{ marginBottom: 12 }} size={8}>
<Select
allowClear
placeholder="그룹 선택"
style={{ width: 140 }}
value={draft.fGroup || undefined}
onChange={(v) => setDraft((d) => ({ ...d, fGroup: v ?? '' }))}
options={groups.map((g) => ({ value: String(g.id), label: g.name }))}
/>
<Select
allowClear
placeholder="지역 선택"
style={{ width: 120 }}
value={draft.fSido || undefined}
onChange={(v) => setDraft((d) => ({ ...d, fSido: v ?? '' }))}
options={sidoOptions.map((s) => ({ value: s, label: s }))}
/>
<Input
placeholder="아이디"
style={{ width: 120 }}
value={draft.fUserid}
onChange={(e) => setDraft((d) => ({ ...d, fUserid: e.target.value }))}
onPressEnter={() => { setApplied(draft); setPage(0) }}
/>
<Input
placeholder="영업점명"
style={{ width: 120 }}
value={draft.fOffice}
onChange={(e) => setDraft((d) => ({ ...d, fOffice: e.target.value }))}
onPressEnter={() => { setApplied(draft); setPage(0) }}
/>
<Input
placeholder="이름"
style={{ width: 100 }}
value={draft.fName}
onChange={(e) => setDraft((d) => ({ ...d, fName: e.target.value }))}
onPressEnter={() => { setApplied(draft); setPage(0) }}
/>
<Button
type="primary"
icon={<SearchOutlined />}
onClick={() => { setApplied(draft); setPage(0) }}
>
검색
</Button>
<Typography.Text type="secondary">총 {filtered.length}건</Typography.Text>
</Space>
<Table
size="small"
rowKey="id"
loading={isLoading}
columns={columns}
dataSource={pageRows}
pagination={{
current: page + 1,
pageSize: PAGE_SIZE,
total: filtered.length,
showSizeChanger: false,
onChange: (p) => setPage(p - 1),
}}
onRow={(r) => ({
onClick: () => setSelectedId(r.id),
className: selectedId === r.id ? 'row-selected' : undefined,
})}
scroll={{ x: 1400 }}
locale={{ emptyText: '영업점이 없습니다.' }}
/>
</Card>
<DraggableModal
title="영업점 등록"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={() => form.submit()}
confirmLoading={createMut.isPending}
okText="등록"
width={860}
destroyOnClose
className="order-form-modal branch-form-modal"
styles={{ body: { maxHeight: 'calc(92vh - 56px)', overflowY: 'auto' } }}
>
<Form
form={form}
layout="vertical"
className="order-form-embedded-inner order-form-page reception-apply-form"
onFinish={(v) => createMut.mutate(v)}
initialValues={{ level: 1, isTax: 1 }}
>
<FormSection title="계정 정보">
<FormGrid>
<FormField label="아이디*">
<Form.Item name="userid" noStyle rules={[{ required: true, message: '아이디를 입력하세요' }]}>
<Input />
</Form.Item>
</FormField>
<FormField label="패스워드*">
<Form.Item name="password" noStyle rules={[{ required: true, message: '패스워드를 입력하세요' }]}>
<Input.Password />
</Form.Item>
</FormField>
</FormGrid>
</FormSection>
<FormSection title="기본 정보">
<FormGrid>
<FormField label="상호(영업점명)*">
<Form.Item name="officeName" noStyle rules={[{ required: true, message: '영업점명을 입력하세요' }]}>
<Input />
</Form.Item>
</FormField>
<FormField label="대표자명*">
<Form.Item name="name" noStyle rules={[{ required: true, message: '대표자명을 입력하세요' }]}>
<Input />
</Form.Item>
</FormField>
<FormField label="전화">
<Form.Item name="tel" noStyle><Input /></Form.Item>
</FormField>
<FormField label="휴대폰*">
<Form.Item name="phone" noStyle rules={[{ required: true, message: '휴대폰을 입력하세요' }]}>
<Input placeholder="010-0000-0000" />
</Form.Item>
</FormField>
<FormField label="이메일*" full>
<Form.Item name="email" noStyle rules={[{ required: true, type: 'email', message: '이메일을 입력하세요' }]}>
<Input />
</Form.Item>
</FormField>
</FormGrid>
</FormSection>
<FormSection title="주소">
<FormGrid>
<FormField label="우편번호">
<Form.Item name="zipcode" noStyle><Input /></Form.Item>
</FormField>
<FormField label="주소">
<Form.Item name="address" noStyle><Input /></Form.Item>
</FormField>
<FormField label="상세주소" full>
<Form.Item name="addressDetail" noStyle><Input /></Form.Item>
</FormField>
</FormGrid>
</FormSection>
<FormSection title="설정">
<FormGrid>
<FormField label="레벨">
<Form.Item name="level" noStyle>
<Select options={[
{ value: 1, label: '영업점' },
{ value: 5, label: '센터' },
{ value: 9, label: '관리자' },
{ value: 10, label: '최고관리자' },
]} />
</Form.Item>
</FormField>
<FormField label="영업점 그룹">
<Form.Item name="branchGroupId" noStyle>
<Select allowClear placeholder="없음" options={groups.map((g) => ({ value: g.id, label: g.name }))} />
</Form.Item>
</FormField>
<FormField label="세금계산서" full>
<Form.Item name="isTax" noStyle>
<Select options={[
{ value: 1, label: '세금계산서발행' },
{ value: 0, label: '종합소득세공제' },
]} />
</Form.Item>
</FormField>
</FormGrid>
</FormSection>
</Form>
</DraggableModal>
<DraggableModal
title="영업점 수정"
open={editOpen}
onCancel={() => setEditOpen(false)}
onOk={() => editForm.submit()}
confirmLoading={updateMut.isPending}
okText="수정"
width={920}
destroyOnClose
className="order-form-modal branch-form-modal"
styles={{ body: { maxHeight: 'calc(92vh - 56px)', overflowY: 'auto' } }}
>
<Form
form={editForm}
layout="vertical"
className="order-form-embedded-inner order-form-page reception-apply-form"
onFinish={(values) => {
const phones: string[] = values.notifyPhones || []
updateMut.mutate({
...values,
isPersonal: values.isPersonal,
acceptPhone: values.acceptPhone ? 1 : 0,
acceptNateon: values.acceptNateon ? 1 : 0,
notifyPhones: phones,
})
}}
>
<EditBranchFormBody groups={groups} centers={centers} />
</Form>
</DraggableModal>
<BranchFeeFlowModals
open={feeOpen}
branchId={feeBranch?.id ?? null}
branchName={feeBranch?.officeName ?? ''}
onClose={() => { setFeeOpen(false); setFeeBranch(null) }}
/>
</div>
)
}
+898
View File
@@ -0,0 +1,898 @@
import { useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Button, Checkbox, DatePicker, Drawer, Input, InputNumber, Radio, Select, Space, Table, message } from 'antd'
import dayjs from 'dayjs'
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
import PreparedDetailEditor from './PreparedDetailEditor'
import { isFeeRowClickTarget, openFeeOrderPopup } from './FeeOrderPopup'
export type Kind = 'branch' | 'center'
export type Summary = {
entityKey: string
title: string
sub: string
contact: string
tax: string
group: string
sido: string
plusCnt: number
minusCnt: number
}
type Line = {
id: number
lineId: number
type: string
typeDesc: string | null
cost: number
event: number
agreement: number
local: number
gift: number
voc: number
etc: number
total: number
memo: string | null
status: string
contractNumber: string
customerName?: string
}
export type Group = {
id: number
entityKey?: string
plus: number
minus: number
total: number
rate: number
isTax: number
taxDate: string | null
completed: string | null
transferred: string | null
bank: string | null
accountNumber: string | null
accountName: string | null
memo: string | null
plusCnt: number
minusCnt: number
branchName?: string | null
officeName?: string | null
centerName?: string | null
}
const TABS = [
{ key: 'prepared', label: '가정산 리스트' },
{ key: 'waiting', label: '정산대기 리스트' },
{ key: 'completed', label: '정산완료 리스트' },
{ key: 'transferred', label: '송금(환수)완료 리스트' },
] as const
type TabKey = (typeof TABS)[number]['key']
const won = (n: number) => (n ?? 0).toLocaleString('ko-KR')
const today = () => dayjs().format('YYYY-MM-DD')
/** 정산 상세 Drawer — 좌측패널까지 덮는 전체화면 */
const fullscreenDrawerProps = {
width: '100%' as const,
placement: 'right' as const,
rootClassName: 'calc-fullscreen-drawer-root',
getContainer: () => document.body,
zIndex: 1200,
destroyOnClose: true,
closable: false,
styles: { body: { paddingTop: 8 } },
}
function CalcDetailDrawerTitle({
label,
onBack,
}: {
label: string
onBack: () => void
}) {
return (
<div className="calc-drawer-titlebar">
<div className="calc-drawer-titlebar-left">
<Button type="default" size="small" className="calc-drawer-back-btn" onClick={onBack}>
목록으로
</Button>
<span className="calc-drawer-title-text">{label}</span>
</div>
</div>
)
}
function CalcDetailDrawerExtra({ onClose }: { onClose: () => void }) {
return (
<Button type="default" size="small" className="calc-drawer-close-btn" onClick={onClose}>
닫기
</Button>
)
}
function cfg(kind: Kind) {
const base = kind === 'branch'
? `${apiPrefix()}/homes/calculate`
: `${apiPrefix()}/homes/center-calculate`
return {
base,
normSummary: (r: Record<string, unknown>): Summary => ({
entityKey: String(kind === 'branch' ? (r.entityKey ?? r.branchId) : (r.entityKey ?? r.centerId)),
title: String(kind === 'branch' ? (r.officeName ?? r.branchName ?? '') : (r.name ?? r.centerName ?? '')),
sub: String(kind === 'branch' ? (r.name ?? '') : (r.manager ?? '')),
contact: String(r.phone || r.tel || ''),
tax: kind === 'branch' ? (r.isTax === 1 ? '세금계산서발행' : '종합소득세공제') : '세금계산서발행',
group: String(kind === 'branch' ? (r.branchGroupName || '') : ''),
sido: String(kind === 'branch' ? (r.sido || '') : ''),
plusCnt: Number(r.plusCnt || 0),
minusCnt: Number(r.minusCnt || 0),
}),
normLine: (r: Record<string, unknown>): Line => ({
id: Number(r.id),
lineId: Number(kind === 'branch' ? (r.orderId ?? r.receptionId) : (r.orderCenterId ?? r.receptionId)),
type: String(r.type || ''),
typeDesc: r.typeDesc == null ? null : String(r.typeDesc),
cost: Number(r.cost || 0),
event: Number(r.event || 0),
agreement: Number(r.agreement || 0),
local: Number(r.local || 0),
gift: Number(r.gift || 0),
voc: Number(r.voc || 0),
etc: Number(r.etc || 0),
total: Number(r.total || 0),
memo: r.memo == null ? null : String(r.memo),
status: String(r.status || ''),
contractNumber: String(r.contractNumber || ''),
customerName: r.customerName ? String(r.customerName) : undefined,
}),
normGroup: (r: Record<string, unknown>): Group => ({
id: Number(r.id),
entityKey: r.entityKey == null ? undefined : String(r.entityKey),
plus: Number(r.plus || 0),
minus: Number(r.minus || 0),
total: Number(r.total || 0),
rate: Number(r.rate || 0),
isTax: Number(r.isTax ?? 1),
taxDate: r.taxDate == null ? null : String(r.taxDate),
completed: r.completed == null ? null : String(r.completed),
transferred: r.transferred == null ? null : String(r.transferred),
bank: r.bank == null ? null : String(r.bank),
accountNumber: r.accountNumber == null ? null : String(r.accountNumber),
accountName: r.accountName == null ? null : String(r.accountName),
memo: r.memo == null ? null : String(r.memo),
plusCnt: Number(r.plusCnt || 0),
minusCnt: Number(r.minusCnt || 0),
branchName: r.branchName == null ? null : String(r.branchName),
officeName: r.officeName == null ? null : String(r.officeName),
centerName: r.centerName == null ? null : String(r.centerName),
}),
}
}
export default function CalculatePage({ kind }: { kind: Kind }) {
const [sp, setSp] = useSearchParams()
const tabLabels = useMemo(() => {
if (kind === 'center') {
return TABS.map((t) => (t.key === 'transferred' ? { ...t, label: '입금완료 리스트' } : t))
}
return [...TABS]
}, [kind])
const [tab, setTab] = useState<TabKey>(() => {
const t = sp.get('tab') as TabKey
return TABS.some((x) => x.key === t) ? t : 'prepared'
})
useEffect(() => {
const t = sp.get('tab') as TabKey
if (t && TABS.some((x) => x.key === t)) {
setTab(t)
} else if (!sp.get('tab')) {
const next = new URLSearchParams(sp)
next.set('tab', 'prepared')
setSp(next, { replace: true })
}
}, [sp, setSp])
const changeTab = (key: TabKey) => {
setTab(key)
const next = new URLSearchParams(sp)
next.set('tab', key)
setSp(next, { replace: true })
}
return (
<div className="stock-page calculate-page">
<div className="stock-title">
<div>
<h2>{kind === 'branch' ? '영업점 정산현황' : '입력처 정산현황'}</h2>
<p>
{kind === 'branch'
? '가정산 → 정산대기 → 정산완료 → 송금(환수)완료 순으로 처리합니다.'
: '가정산 → 정산대기 → 정산완료 → 입금완료 순으로 처리합니다.'}
</p>
</div>
</div>
<div className="toolbar" style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 16 }}>
{tabLabels.map((t) => (
<Button key={t.key} type={tab === t.key ? 'primary' : 'default'} onClick={() => changeTab(t.key)}>
{t.label}
</Button>
))}
</div>
{tab === 'prepared' && <PreparedTab kind={kind} />}
{tab === 'waiting' && <WaitingTab kind={kind} />}
{tab === 'completed' && <CompletedTab kind={kind} />}
{tab === 'transferred' && <TransferredTab kind={kind} />}
</div>
)
}
function PreparedTab({ kind }: { kind: Kind }) {
const c = cfg(kind)
const { data, isLoading, refetch } = useQuery({
queryKey: ['calc', kind, 'prepared'],
queryFn: async () => {
const { data } = await client.get<ApiResponse<Record<string, unknown>[]>>(`${c.base}/prepared`)
return (unwrap(data) || []).map(c.normSummary)
},
})
const [open, setOpen] = useState<Summary | null>(null)
return (
<>
<SummaryTable rows={data} kind={kind} loading={isLoading} onOpen={setOpen} />
<Drawer
open={!!open}
onClose={() => setOpen(null)}
{...fullscreenDrawerProps}
title={open ? <CalcDetailDrawerTitle label={`${open.title} — 가정산 상세리스트`} onBack={() => setOpen(null)} /> : null}
extra={open ? <CalcDetailDrawerExtra onClose={() => setOpen(null)} /> : null}
>
{open && (
<PreparedDetailEditor
kind={kind}
row={open}
onDone={() => { setOpen(null); refetch() }}
/>
)}
</Drawer>
</>
)
}
function WaitingTab({ kind }: { kind: Kind }) {
const c = cfg(kind)
const { data, isLoading, refetch } = useQuery({
queryKey: ['calc', kind, 'waiting'],
queryFn: async () => {
const { data } = await client.get<ApiResponse<Record<string, unknown>[]>>(`${c.base}/waiting`)
return (unwrap(data) || []).map(c.normSummary)
},
})
const [open, setOpen] = useState<Summary | null>(null)
return (
<>
<SummaryTable rows={data} kind={kind} loading={isLoading} onOpen={setOpen} />
<Drawer
open={!!open}
onClose={() => setOpen(null)}
{...fullscreenDrawerProps}
title={open ? <CalcDetailDrawerTitle label={`${open.title} — 정산대기 상세리스트`} onBack={() => setOpen(null)} /> : null}
extra={open ? <CalcDetailDrawerExtra onClose={() => setOpen(null)} /> : null}
>
{open && (
<WaitingEditor
kind={kind}
row={open}
onDone={() => { setOpen(null); refetch() }}
/>
)}
</Drawer>
</>
)
}
function WaitingEditor({ kind, row, onDone }: { kind: Kind; row: Summary; onDone: () => void }) {
const c = cfg(kind)
const qc = useQueryClient()
const linesKey = ['calc', kind, 'waitingLines', row.entityKey]
const { data } = useQuery({
queryKey: linesKey,
queryFn: async () => {
const { data } = await client.get<ApiResponse<Record<string, unknown>[]>>(`${c.base}/waiting/lines`, {
params: { entity: row.entityKey },
})
return (unwrap(data) || []).map(c.normLine)
},
})
const { data: completedGroups } = useQuery({
queryKey: ['calc', kind, 'completed', 'merge', row.entityKey],
queryFn: async () => {
const { data } = await client.get<ApiResponse<Record<string, unknown>[]>>(`${c.base}/completed`, {
params: { entity: row.entityKey },
})
return (unwrap(data) || []).map(c.normGroup)
},
})
const [edits, setEdits] = useState<Record<number, Line>>({})
const lines = useMemo(() => (data ?? []).map((l) => edits[l.id] ?? l), [data, edits])
const [plus, setPlus] = useState(0)
const [minus, setMinus] = useState(0)
const [completed, setCompleted] = useState(today())
const [memo, setMemo] = useState('')
const [mergeMode, setMergeMode] = useState<'new' | 'merge'>('new')
const [mergeGroupId, setMergeGroupId] = useState<number | undefined>()
const total = lines.reduce((a, l) => a + l.total, 0) + plus + minus
const patch = (id: number, field: keyof Line, value: number) => {
setEdits((prev) => {
const base = prev[id] ?? data!.find((d) => d.id === id)!
const next = { ...base, [field]: value }
next.total = next.cost + next.event + next.agreement + next.local + next.gift + next.voc + next.etc
return { ...prev, [id]: next }
})
}
const saveLine = useMutation({
mutationFn: async (l: Line) => {
const { data } = await client.post(`${c.base}/cost/${l.id}`, {
cost: l.cost, event: l.event, agreement: l.agreement, local: l.local,
gift: l.gift, voc: l.voc, etc: l.etc, memo: l.memo,
})
return unwrap(data)
},
onSuccess: () => { setEdits({}); qc.invalidateQueries({ queryKey: linesKey }); message.success('저장됨') },
onError: (e: Error) => message.error(e.message),
})
const revert = useMutation({
mutationFn: async (lineId: number) => {
const { data } = await client.post(`${c.base}/to-prepared`, { receptionIds: [lineId] })
return unwrap(data)
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: linesKey })
qc.invalidateQueries({ queryKey: ['calc', kind] })
message.success('가정산으로 되돌렸습니다.')
},
onError: (e: Error) => message.error(e.message),
})
const submit = useMutation({
mutationFn: async () => {
if (mergeMode === 'merge' && !mergeGroupId) throw new Error('결합할 기존 그룹을 선택하세요.')
const { data } = await client.post(`${c.base}/toss-completed`, {
entity: row.entityKey,
plus, minus, completed, memo,
mergeGroupId: mergeMode === 'merge' ? mergeGroupId : null,
})
return unwrap(data)
},
onSuccess: () => { message.success('정산완료 처리되었습니다.'); onDone() },
onError: (e: Error) => message.error(e.message),
})
return (
<div>
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={lines}
onRow={(r) => ({
style: { cursor: 'pointer' },
onClick: (e) => {
if (!isFeeRowClickTarget(e.target)) return
void openFeeOrderPopup(r.lineId, kind)
},
})}
columns={[
{ title: '접수', width: 90, render: (_: unknown, r: Line) => r.contractNumber || r.lineId },
{ title: '고객', width: 90, dataIndex: 'customerName' },
{ title: '구분', width: 70, render: (_: unknown, r: Line) => (r.type.endsWith('01') ? '정산' : (r.typeDesc || '환수')) },
...(['cost', 'event', 'agreement', 'local', 'gift', 'voc', 'etc'] as const).map((f) => ({
title: ({ cost: '단가', event: '이벤트', agreement: '약정', local: '지역', gift: '사은품', voc: 'VOC', etc: '기타' } as const)[f],
width: 90,
align: 'right' as const,
render: (_: unknown, r: Line) => (
<InputNumber
size="small"
value={r[f]}
style={{ width: 80 }}
className="calc-money-input"
formatter={(v) => `${v ?? ''}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
parser={(v) => Number((v ?? '').replace(/,/g, '') || 0) as unknown as 0}
onChange={(v) => patch(r.id, f, Number(v || 0))}
/>
),
})),
{ title: '합계', width: 100, align: 'right' as const, render: (_: unknown, r: Line) => <b>{won(r.total)}</b> },
{
title: '작업',
width: 150,
align: 'center' as const,
render: (_: unknown, r: Line) => (
<Space size={4}>
<Button size="small" type="primary" className="branch-row-btn" onClick={(e) => { e.stopPropagation(); saveLine.mutate(r) }}>저장</Button>
<Button
size="small"
type="primary"
danger
className="branch-row-btn"
onClick={(e) => {
e.stopPropagation()
if (window.confirm('이 접수를 가정산으로 되돌립니다.')) revert.mutate(r.lineId)
}}
>
되돌리기
</Button>
</Space>
),
},
{
title: '수수료현황',
width: 100,
align: 'center' as const,
fixed: 'right' as const,
render: (_: unknown, r: Line) => (
<Button
size="small"
type="primary"
className="branch-row-btn fee-status-row-btn"
onClick={(e) => {
e.stopPropagation()
void openFeeOrderPopup(r.lineId, kind)
}}
>
수수료현황
</Button>
),
},
]}
scroll={{ x: 1200 }}
/>
<Space wrap style={{ marginTop: 16 }} size={12}>
<span>기타정산 <InputNumber value={plus} onChange={(v) => setPlus(Number(v || 0))} /></span>
<span>기타차감 <InputNumber value={minus} onChange={(v) => setMinus(Number(v || 0))} /></span>
<span>정산일자 <DatePicker value={dayjs(completed)} onChange={(d) => setCompleted(d?.format('YYYY-MM-DD') || today())} /></span>
<span>메모 <Input value={memo} onChange={(e) => setMemo(e.target.value)} style={{ width: 180 }} /></span>
</Space>
<div style={{ marginTop: 12 }}>
<Radio.Group value={mergeMode} onChange={(e) => { setMergeMode(e.target.value); setMergeGroupId(undefined) }}>
<Radio value="new">새 그룹으로 정산완료</Radio>
<Radio value="merge" disabled={!completedGroups?.length}>기존 그룹에 결합</Radio>
</Radio.Group>
{mergeMode === 'merge' && (
<Select
style={{ minWidth: 360, marginLeft: 12 }}
placeholder="결합할 그룹 선택"
value={mergeGroupId}
onChange={setMergeGroupId}
options={(completedGroups || []).map((g) => ({
value: g.id,
label: `#${g.id} 정산일 ${g.completed?.slice(0, 10) || '-'} · ${won(g.total)}원 · 정산 ${g.plusCnt}/환수 ${g.minusCnt}`,
}))}
/>
)}
</div>
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'flex-end', gap: 12, alignItems: 'center' }}>
<span>최종합계 <b>{won(total)}</b> 원</span>
<Button type="primary" loading={submit.isPending} onClick={() => submit.mutate()} disabled={!lines.length}>
정산완료 처리
</Button>
</div>
</div>
)
}
function CompletedTab({ kind }: { kind: Kind }) {
return <GroupTable kind={kind} mode="completed" />
}
function TransferredTab({ kind }: { kind: Kind }) {
return <GroupTable kind={kind} mode="transferred" />
}
function GroupTable({ kind, mode }: { kind: Kind; mode: 'completed' | 'transferred' }) {
const c = cfg(kind)
const endpoint = mode === 'completed' ? 'completed' : 'transferred'
const { data, isLoading, refetch } = useQuery({
queryKey: ['calc', kind, endpoint],
queryFn: async () => {
const { data } = await client.get<ApiResponse<Record<string, unknown>[]>>(`${c.base}/${endpoint}`)
return (unwrap(data) || []).map(c.normGroup)
},
})
const [open, setOpen] = useState<Group | null>(null)
return (
<>
<Table
rowKey="id"
size="small"
loading={isLoading}
dataSource={data || []}
pagination={{ pageSize: 20 }}
onRow={(r) => ({ onClick: () => setOpen(r), style: { cursor: 'pointer' } })}
columns={[
{ title: '그룹#', dataIndex: 'id', width: 80 },
{
title: kind === 'branch' ? '영업점' : '입력처',
render: (_: unknown, r: Group) => r.officeName || r.branchName || r.centerName || '-',
},
{ title: '정산', width: 70, dataIndex: 'plusCnt', align: 'right' as const },
{ title: '환수', width: 70, dataIndex: 'minusCnt', align: 'right' as const },
{ title: '합계', width: 110, align: 'right' as const, render: (v: number) => won(v) },
{ title: '완료일', width: 120, render: (_: unknown, r: Group) => r.completed?.slice(0, 10) || '-' },
{
title: kind === 'branch' ? '송금일' : '입금일',
width: 120,
render: (_: unknown, r: Group) => r.transferred?.slice(0, 10) || '-',
},
{ title: '메모', dataIndex: 'memo', ellipsis: true },
{
title: '상세보기',
width: 100,
align: 'center' as const,
fixed: 'right' as const,
render: (_: unknown, r: Group) => (
<Button
size="small"
type="primary"
className="branch-row-btn detail-view-row-btn"
onClick={(e) => {
e.stopPropagation()
setOpen(r)
}}
>
상세보기
</Button>
),
},
]}
scroll={{ x: 1100 }}
/>
<Drawer
open={!!open}
onClose={() => setOpen(null)}
{...fullscreenDrawerProps}
title={open ? (
<CalcDetailDrawerTitle
label={
mode === 'completed'
? `정산 #${open.id} — 정산완료 상세리스트`
: `정산 #${open.id} — ${kind === 'branch' ? '송금(환수)완료' : '입금완료'} 상세리스트`
}
onBack={() => setOpen(null)}
/>
) : null}
extra={open ? <CalcDetailDrawerExtra onClose={() => setOpen(null)} /> : null}
>
{open && (
<TransferEditor
kind={kind}
group={open}
editMode={mode}
onDone={() => { setOpen(null); refetch() }}
/>
)}
</Drawer>
</>
)
}
function TransferEditor({
kind, group, editMode, onDone,
}: {
kind: Kind
group: Group
editMode: 'completed' | 'transferred'
onDone: () => void
}) {
const c = cfg(kind)
const isTransferredEdit = editMode === 'transferred' || !!group.transferred
const { data: lines } = useQuery({
queryKey: ['calc', kind, 'groupLines', group.id],
queryFn: async () => {
const { data } = await client.get<ApiResponse<Record<string, unknown>[]>>(`${c.base}/group/${group.id}/lines`)
return (unwrap(data) || []).map(c.normLine)
},
})
const [ratePercent, setRatePercent] = useState(group.isTax === 1 ? 10 : -3.3)
const [taxBill, setTaxBill] = useState(group.isTax === 1)
const [taxDate, setTaxDate] = useState(group.taxDate?.slice(0, 10) || today())
const [transferred, setTransferred] = useState(group.transferred?.slice(0, 10) || today())
const [plus, setPlus] = useState(group.plus ?? 0)
const [minus, setMinus] = useState(group.minus ?? 0)
const [completed, setCompleted] = useState(group.completed?.slice(0, 10) || today())
const [memo, setMemo] = useState(group.memo || '')
const [bank, setBank] = useState(group.bank || '')
const [accountNumber, setAccountNumber] = useState(group.accountNumber || '')
const [accountName, setAccountName] = useState(group.accountName || '')
const tax = Math.round(group.total * (ratePercent / 100))
const payout = group.total + tax
const submit = useMutation({
mutationFn: async () => {
const { data } = await client.post(`${c.base}/group/${group.id}/toss-transferred`, {
rate: ratePercent, taxBill, taxDate, transferred,
bank, accountNumber, accountName, memo,
})
return unwrap(data)
},
onSuccess: () => {
message.success(kind === 'branch' ? '송금완료 처리되었습니다.' : '입금완료 처리되었습니다.')
onDone()
},
onError: (e: Error) => message.error(e.message),
})
const update = useMutation({
mutationFn: async () => {
const path = isTransferredEdit
? `${c.base}/group/${group.id}/transferred`
: `${c.base}/group/${group.id}`
const body = isTransferredEdit
? { plus, minus, completed, transferred, rate: ratePercent, taxBill, taxDate, memo, bank, accountNumber, accountName }
: { plus, minus, completed, memo }
const { data } = await client.post(path, body)
return unwrap(data)
},
onSuccess: () => { message.success('수정 저장되었습니다.'); onDone() },
onError: (e: Error) => message.error(e.message),
})
const cancel = useMutation({
mutationFn: async () => {
const { data } = await client.post(`${c.base}/group/${group.id}/cancel`)
return unwrap(data)
},
onSuccess: () => { message.success('정산대기로 되돌렸습니다.'); onDone() },
onError: (e: Error) => message.error(e.message),
})
return (
<div>
<Table
rowKey="id"
size="small"
pagination={false}
dataSource={lines || []}
onRow={(r) => ({
style: { cursor: 'pointer' },
onClick: (e) => {
if (!isFeeRowClickTarget(e.target)) return
void openFeeOrderPopup(r.lineId, kind)
},
})}
columns={[
{ title: '접수', width: 90, render: (_: unknown, r: Line) => r.contractNumber || r.lineId },
{ title: '고객', dataIndex: 'customerName', width: 90 },
{ title: '구분', width: 70, render: (_: unknown, r: Line) => (r.type.endsWith('01') ? '정산' : (r.typeDesc || '환수')) },
{ title: '단가', dataIndex: 'cost', width: 80, align: 'right' as const, render: won },
{ title: '이벤트', dataIndex: 'event', width: 80, align: 'right' as const, render: won },
{ title: '약정', dataIndex: 'agreement', width: 80, align: 'right' as const, render: won },
{ title: '지역', dataIndex: 'local', width: 80, align: 'right' as const, render: won },
{ title: '사은품', dataIndex: 'gift', width: 80, align: 'right' as const, render: won },
{ title: 'VOC', dataIndex: 'voc', width: 80, align: 'right' as const, render: won },
{ title: '기타', dataIndex: 'etc', width: 80, align: 'right' as const, render: won },
{ title: '합계', dataIndex: 'total', width: 90, align: 'right' as const, render: (v: number) => <b>{won(v)}</b> },
{
title: '수수료현황',
width: 100,
align: 'center' as const,
fixed: 'right' as const,
render: (_: unknown, r: Line) => (
<Button
size="small"
type="primary"
className="branch-row-btn fee-status-row-btn"
onClick={(e) => {
e.stopPropagation()
void openFeeOrderPopup(r.lineId, kind)
}}
>
수수료현황
</Button>
),
},
]}
scroll={{ x: 1100 }}
/>
<Space wrap style={{ marginTop: 16 }} size={12}>
<b>{isTransferredEdit ? (kind === 'branch' ? '송금완료 수정' : '입금완료 수정') : '정산완료 수정'}</b>
<span>기타정산 <InputNumber value={plus} onChange={(v) => setPlus(Number(v || 0))} /></span>
<span>기타차감 <InputNumber value={minus} onChange={(v) => {
const n = Number(v || 0)
setMinus(n > 0 ? -Math.abs(n) : n)
}} /></span>
<span>정산일자 <DatePicker value={dayjs(completed)} onChange={(d) => setCompleted(d?.format('YYYY-MM-DD') || today())} /></span>
{isTransferredEdit && (
<span>{kind === 'branch' ? '송금일' : '입금일'}{' '}
<DatePicker value={dayjs(transferred)} onChange={(d) => setTransferred(d?.format('YYYY-MM-DD') || today())} />
</span>
)}
<span>메모 <Input value={memo} onChange={(e) => setMemo(e.target.value)} style={{ width: 160 }} /></span>
{kind === 'branch' && isTransferredEdit && (
<>
<span>은행 <Input value={bank} onChange={(e) => setBank(e.target.value)} style={{ width: 80 }} /></span>
<span>계좌 <Input value={accountNumber} onChange={(e) => setAccountNumber(e.target.value)} style={{ width: 120 }} /></span>
<span>예금주 <Input value={accountName} onChange={(e) => setAccountName(e.target.value)} style={{ width: 80 }} /></span>
</>
)}
<Button type="primary" className="branch-row-btn" onClick={() => update.mutate()}>수정 저장</Button>
</Space>
{!isTransferredEdit && (
<Space wrap style={{ marginTop: 16 }} size={12}>
<Checkbox checked={taxBill} onChange={(e) => {
setTaxBill(e.target.checked)
setRatePercent(e.target.checked ? 10 : -3.3)
}}>세금계산서 발행</Checkbox>
<span>세율(%) <InputNumber step={0.1} value={ratePercent} onChange={(v) => setRatePercent(Number(v || 0))} /></span>
{taxBill && (
<span>발행일 <DatePicker value={dayjs(taxDate)} onChange={(d) => setTaxDate(d?.format('YYYY-MM-DD') || today())} /></span>
)}
<span>{kind === 'branch' ? '송금일' : '입금일'}{' '}
<DatePicker value={dayjs(transferred)} onChange={(d) => setTransferred(d?.format('YYYY-MM-DD') || today())} />
</span>
<span style={{ marginLeft: 8 }}>정산합계 {won(group.total)} / 세액 {won(tax)} / 지급액 <b>{won(payout)}</b></span>
<Button type="primary" className="branch-row-btn" loading={submit.isPending} onClick={() => submit.mutate()}>
{kind === 'branch' ? '송금완료' : '입금완료'}
</Button>
<Button
type="primary"
danger
className="branch-row-btn"
onClick={() => {
if (window.confirm('정산완료를 취소하고 정산대기로 되돌립니다.')) cancel.mutate()
}}
>
정산취소
</Button>
</Space>
)}
{isTransferredEdit && (
<Space wrap style={{ marginTop: 16 }} size={12}>
<Checkbox checked={taxBill} onChange={(e) => {
setTaxBill(e.target.checked)
setRatePercent(e.target.checked ? 10 : -3.3)
}}>세금계산서 발행</Checkbox>
<span>세율(%) <InputNumber step={0.1} value={ratePercent} onChange={(v) => setRatePercent(Number(v || 0))} /></span>
{taxBill && (
<span>발행일 <DatePicker value={dayjs(taxDate)} onChange={(d) => setTaxDate(d?.format('YYYY-MM-DD') || today())} /></span>
)}
<span>정산합계 {won(group.total)} / 세액 {won(tax)} / 지급액 <b>{won(payout)}</b></span>
</Space>
)}
</div>
)
}
function SummaryTable({
rows, kind, loading, onOpen,
}: {
rows?: Summary[]
kind: Kind
loading?: boolean
onOpen: (row: Summary) => void
}) {
const [draft, setDraft] = useState({ group: '', title: '', sub: '', contact: '', sido: '', tax: '' })
const [applied, setApplied] = useState(draft)
const groups = useMemo(() => Array.from(new Set((rows ?? []).map((r) => r.group).filter(Boolean))).sort(), [rows])
const sidos = useMemo(() => Array.from(new Set((rows ?? []).map((r) => r.sido).filter(Boolean))).sort(), [rows])
const inc = (v: string | undefined, q: string) => (v ?? '').toLowerCase().includes(q.toLowerCase())
const filtered = (rows ?? []).filter((b) =>
(!applied.group || b.group === applied.group)
&& (!applied.title || inc(b.title, applied.title))
&& (!applied.sub || inc(b.sub, applied.sub))
&& (!applied.contact || inc(b.contact, applied.contact))
&& (!applied.sido || b.sido === applied.sido)
&& (!applied.tax || b.tax === applied.tax))
const detailCol = {
title: '상세보기',
width: 100,
align: 'center' as const,
fixed: 'right' as const,
render: (_: unknown, r: Summary) => (
<Button
size="small"
type="primary"
className="branch-row-btn detail-view-row-btn"
onClick={(e) => {
e.stopPropagation()
onOpen(r)
}}
>
상세보기
</Button>
),
}
const branchCols = [
{ title: '그룹명', dataIndex: 'group', width: 120 },
{ title: '영업점명', dataIndex: 'title' },
{ title: '이름', dataIndex: 'sub', width: 100 },
{ title: '연락처', dataIndex: 'contact', width: 130 },
{ title: '지역', dataIndex: 'sido', width: 90 },
{ title: '세금계산서', dataIndex: 'tax', width: 130 },
{
title: '정산 / 환수',
width: 110,
align: 'right' as const,
render: (_: unknown, r: Summary) => (
<b>{(r.plusCnt ?? 0).toLocaleString('ko-KR')} / {(r.minusCnt ?? 0).toLocaleString('ko-KR')}</b>
),
},
detailCol,
]
const centerCols = [
{ title: '입력처명', dataIndex: 'title' },
{ title: '이름', dataIndex: 'sub', width: 100 },
{ title: '연락처', dataIndex: 'contact', width: 130 },
{
title: '정산 / 환수',
width: 110,
align: 'right' as const,
render: (_: unknown, r: Summary) => (
<b>{(r.plusCnt ?? 0).toLocaleString('ko-KR')} / {(r.minusCnt ?? 0).toLocaleString('ko-KR')}</b>
),
},
detailCol,
]
return (
<div className="summary-table-wrap">
<Space wrap style={{ marginBottom: 12 }}>
{kind === 'branch' && (
<>
<Select allowClear placeholder="그룹" style={{ width: 140 }} value={draft.group || undefined}
onChange={(v) => setDraft((p) => ({ ...p, group: v || '' }))}
options={groups.map((g) => ({ value: g, label: g }))} />
<Input placeholder="영업점명" value={draft.title} onChange={(e) => setDraft((p) => ({ ...p, title: e.target.value }))} style={{ width: 140 }} />
<Input placeholder="이름" value={draft.sub} onChange={(e) => setDraft((p) => ({ ...p, sub: e.target.value }))} style={{ width: 100 }} />
<Input placeholder="연락처" value={draft.contact} onChange={(e) => setDraft((p) => ({ ...p, contact: e.target.value }))} style={{ width: 120 }} />
<Select allowClear placeholder="지역" style={{ width: 110 }} value={draft.sido || undefined}
onChange={(v) => setDraft((p) => ({ ...p, sido: v || '' }))}
options={sidos.map((s) => ({ value: s, label: s }))} />
<Select allowClear placeholder="세금계산서" style={{ width: 150 }} value={draft.tax || undefined}
onChange={(v) => setDraft((p) => ({ ...p, tax: v || '' }))}
options={[
{ value: '세금계산서발행', label: '세금계산서발행' },
{ value: '종합소득세공제', label: '종합소득세공제' },
]} />
</>
)}
{kind === 'center' && (
<>
<Input placeholder="입력처명" value={draft.title} onChange={(e) => setDraft((p) => ({ ...p, title: e.target.value }))} style={{ width: 160 }} />
<Input placeholder="이름" value={draft.sub} onChange={(e) => setDraft((p) => ({ ...p, sub: e.target.value }))} style={{ width: 100 }} />
<Input placeholder="연락처" value={draft.contact} onChange={(e) => setDraft((p) => ({ ...p, contact: e.target.value }))} style={{ width: 120 }} />
</>
)}
<Button type="primary" onClick={() => setApplied({ ...draft })}>검색</Button>
</Space>
<p className="muted" style={{ marginBottom: 8, fontSize: 12 }}>행 클릭 또는 상세보기 버튼으로 상세리스트가 열립니다.</p>
<Table
rowKey="entityKey"
size="small"
loading={loading}
dataSource={filtered}
pagination={{ pageSize: 30 }}
onRow={(r) => ({ onClick: () => onOpen(r), style: { cursor: 'pointer' } })}
columns={kind === 'branch' ? branchCols : centerCols}
scroll={{ x: kind === 'branch' ? 1100 : 700 }}
/>
</div>
)
}
@@ -448,7 +448,7 @@ export default function ContractBalancePage() {
</Form.Item>
<div className="balance-modal-footer">
<Button className="balance-submit-btn" type="primary" htmlType="submit" loading={save.isPending}>
{editing ? '수정합니다' : '등록'}
{editing ? '수정' : '등록'}
</Button>
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
</div>
@@ -424,7 +424,7 @@ export default function ContractWritePage() {
<div className="open-actions">
<Button onClick={() => navigate(back)}>목록</Button>
<Button type="primary" htmlType="submit" loading={save.isPending}>{id ? '수정합니다' : '등록'}</Button>
<Button type="primary" htmlType="submit" loading={save.isPending}>{id ? '수정' : '등록'}</Button>
</div>
</Form>
+1 -1
View File
@@ -660,7 +660,7 @@ export default function CustomerPage() {
</Form.Item>
<div className="balance-modal-footer">
<Button className="customer-submit-btn" type="primary" htmlType="submit" loading={save.isPending}>
{editing ? '수정합니다' : '등록'}
{editing ? '수정' : '등록'}
</Button>
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
</div>
+1 -1
View File
@@ -276,7 +276,7 @@ export default function DeliveryPage() {
</Form.Item>
<div className="sms-send-footer">
<Button type="primary" htmlType="submit" loading={saveMut.isPending} icon={<EditOutlined />}>
{editing ? '수정합니다' : '등록'}
{editing ? '수정' : '등록'}
</Button>
<Button onClick={() => { setOpen(false); setEditing(null) }}>취소</Button>
</div>
+178
View File
@@ -0,0 +1,178 @@
import type { ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { Button, ConfigProvider, Table, theme as antTheme, message } from 'antd'
import koKR from 'antd/locale/ko_KR'
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
import DraggableModal from '../../components/DraggableModal'
export type FeeOrderView = {
kind: string
type: string
feeId: number
entityName: string
ispName: string
startDate: string
memo?: string | null
products: { productType: string; productName: string; fee: number }[]
}
const PRODUCT_TYPE: Record<string, string> = {
e01: '인터넷', e02: '전화', e03: 'TV', e04: '렌탈',
}
/** 정산 상세 Drawer(zIndex 1200) 위에 표시 */
const FEE_POPUP_Z = 2200
const won = (n: number) => `${(n ?? 0).toLocaleString('ko-KR')} 원`
let activeRoot: Root | null = null
let activeHost: HTMLDivElement | null = null
function unmountFeePopup() {
if (activeRoot) {
try {
activeRoot.unmount()
} catch {
/* ignore */
}
activeRoot = null
}
if (activeHost) {
activeHost.remove()
activeHost = null
}
}
function mountFeePopup(node: ReactNode) {
unmountFeePopup()
const host = document.createElement('div')
host.className = 'fee-order-popup-host'
document.body.appendChild(host)
activeHost = host
const root = createRoot(host)
activeRoot = root
root.render(
<ConfigProvider locale={koKR} theme={{ algorithm: antTheme.defaultAlgorithm }}>
{node}
</ConfigProvider>,
)
}
/**
* billing_imeere branch-group-fee/order · center-fee/order 대응.
* 수수료 있으면 현황 모달, 없으면 "적용가능한 수수료가 없습니다." 알림.
*/
export async function openFeeOrderPopup(receptionId: number, kind: 'branch' | 'center') {
if (!receptionId) {
showAlertModal('접수 정보를 확인할 수 없습니다.')
return
}
try {
const { data } = await client.get<ApiResponse<FeeOrderView>>(
`${apiPrefix()}/homes/fee/order/${receptionId}`,
{ params: { kind } },
)
const view = unwrap(data)
showFeeStatusModal(view, kind)
} catch (e) {
const msg = e instanceof Error ? e.message : '적용가능한 수수료가 없습니다.'
showAlertModal(msg || '적용가능한 수수료가 없습니다.')
}
}
function showAlertModal(content: string) {
const close = () => unmountFeePopup()
mountFeePopup(
<DraggableModal
open
title="수수료 현황"
centered
zIndex={FEE_POPUP_Z}
onCancel={close}
className="calc-fee-alert-modal"
footer={(
<div style={{ textAlign: 'right' }}>
<Button type="primary" onClick={close}>확인</Button>
</div>
)}
>
<div>{content}</div>
</DraggableModal>,
)
}
function showFeeStatusModal(view: FeeOrderView, kind: 'branch' | 'center') {
const entityLabel = kind === 'center' ? '입력처' : '영업점'
const close = () => unmountFeePopup()
mountFeePopup(
<DraggableModal
open
title="수수료 현황"
width={720}
centered
zIndex={FEE_POPUP_Z}
onCancel={close}
className="calc-fee-status-modal"
footer={(
<div style={{ textAlign: 'right' }}>
<Button type="primary" className="branch-row-btn" onClick={close}>닫기</Button>
</div>
)}
>
<div className="fee-order-popup">
<table className="prg-table" style={{ marginBottom: 12 }}>
<tbody>
<tr><th style={{ width: 110 }}>{entityLabel}</th><td>{view.entityName}</td></tr>
<tr><th>통신사</th><td>{view.ispName}</td></tr>
<tr><th>적용날짜</th><td>{view.startDate}</td></tr>
</tbody>
</table>
<div style={{ marginBottom: 6, fontWeight: 600 }}>상품별 수수료</div>
<Table
size="small"
pagination={false}
rowKey={(r) => `${r.productType}-${r.productName}`}
dataSource={view.products || []}
locale={{ emptyText: '상품별 수수료가 없습니다.' }}
columns={[
{
title: '상품종류',
width: 100,
align: 'center',
render: (_: unknown, r) => PRODUCT_TYPE[r.productType] || r.productType,
},
{ title: '상품명', dataIndex: 'productName', align: 'center' },
{
title: '적용금액',
width: 140,
align: 'right',
render: (_: unknown, r) => won(r.fee),
},
]}
/>
{view.memo ? (
<table className="prg-table" style={{ marginTop: 12 }}>
<tbody>
<tr><th style={{ width: 110 }}>메모</th><td style={{ whiteSpace: 'pre-wrap' }}>{view.memo}</td></tr>
</tbody>
</table>
) : null}
</div>
</DraggableModal>,
)
}
/** 행 클릭 시 입력/체크 영역은 무시 */
export function isFeeRowClickTarget(target: EventTarget | null) {
if (!(target instanceof HTMLElement)) return false
return !target.closest(
'input,button,a,textarea,.ant-checkbox,.ant-input,.ant-input-number,.ant-select,.ant-picker',
)
}
export function toastFeeError(err: unknown) {
message.error(err instanceof Error ? err.message : '수수료 조회 실패')
}
+1 -1
View File
@@ -331,7 +331,7 @@ export default function InCompanyPage() {
</Form.Item>
<div className="sms-send-footer">
<Button type="primary" htmlType="submit" loading={saveMut.isPending} icon={<EditOutlined />}>
{editing ? '수정합니다' : '등록'}
{editing ? '수정' : '등록'}
</Button>
<Button onClick={() => { setOpen(false); setEditing(null) }}>취소</Button>
</div>
@@ -252,7 +252,7 @@ export default function InternalBoardPage({ code, title }: Props) {
</Form.Item>
<div className="bbs-write-actions">
<Button type="primary" htmlType="submit" loading={saveMut.isPending}>
{editing ? '수정합니다' : '등록'}
{editing ? '수정' : '등록'}
</Button>
<Button onClick={() => { setOpen(false); setEditing(null) }}>취소</Button>
</div>
+1 -1
View File
@@ -345,7 +345,7 @@ export default function OpenBalancePage() {
</Form.Item>
<div className="balance-modal-footer">
<Button type="primary" htmlType="submit" loading={save.isPending} icon={<PlusOutlined />}>
{editing ? '수정합니다' : '등록'}
{editing ? '수정' : '등록'}
</Button>
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
</div>
+1 -1
View File
@@ -228,7 +228,7 @@ export default function OpenUnitCostPage() {
<Radio.Group options={[{ value: '표준', label: '표준' }, { value: '공시/선택구분', label: '공시/선택구분' }]} />
</Form.Item>
<div className="unitcost-modal-footer">
<Button type="primary" htmlType="submit" loading={save.isPending}>{editing ? '수정합니다' : '생성합니다'}</Button>
<Button type="primary" htmlType="submit" loading={save.isPending}>{editing ? '수정' : '생성합니다'}</Button>
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
</div>
</Form>
+1 -1
View File
@@ -418,7 +418,7 @@ export default function OutCompanyPage() {
</Form.Item>
<div className="sms-send-footer">
<Button type="primary" htmlType="submit" loading={saveMut.isPending} icon={<EditOutlined />}>
{editing ? '수정합니다' : '등록'}
{editing ? '수정' : '등록'}
</Button>
<Button onClick={() => { setOpen(false); setEditing(null) }}>취소</Button>
</div>
@@ -414,7 +414,7 @@ export default function PendingCalendarPage() {
</Form.Item>
<div className="balance-modal-footer">
<Button className="pending-submit-btn" type="primary" htmlType="submit" loading={save.isPending}>
{editing ? '수정합니다' : '등록'}
{editing ? '수정' : '등록'}
</Button>
<Button onClick={closeModal}>취소</Button>
</div>
+1 -1
View File
@@ -495,7 +495,7 @@ export default function PendingPage() {
</Form.Item>
<div className="balance-modal-footer">
<Button className="pending-submit-btn" type="primary" htmlType="submit" loading={save.isPending}>
{editing ? '수정합니다' : '등록'}
{editing ? '수정' : '등록'}
</Button>
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
</div>
@@ -0,0 +1,865 @@
import { useEffect, useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Button, Checkbox, DatePicker, Input, InputNumber, Radio, Select, Space, Table, message } from 'antd'
import dayjs from 'dayjs'
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
import { GIFT_PLACE, GIFT_STATUS, progressLabel } from './receptionStatuses'
import type { Kind, Summary } from './CalculatePage'
import { isFeeRowClickTarget, openFeeOrderPopup } from './FeeOrderPopup'
const won = (n: number) => (n ?? 0).toLocaleString('ko-KR')
const moneyInputProps = {
size: 'small' as const,
style: { width: 76, textAlign: 'right' as const },
formatter: (v: string | number | undefined) =>
`${v ?? ''}`.replace(/\B(?=(\d{3})+(?!\d))/g, ','),
parser: (v: string | undefined) => Number((v ?? '').replace(/,/g, '') || 0) as unknown as 0,
}
const today = () => dayjs().format('YYYY-MM-DD')
const SIDO_LIST = ['서울', '경기', '인천', '강원', '충북', '충남', '대전', '전북', '전남', '광주', '경북', '대구', '경남', '울산', '부산', '제주']
const PRODUCT_TYPE: Record<string, string> = {
e01: '인터넷', e02: '전화', e03: 'TV', e04: '렌탈',
}
export type PreparedRow = {
orderId: number
receptionId?: number
status: string
plus: boolean
orderDate: string
opened: string
centerName: string
ispName: string
productType: string
productName: string
eventNames: string
agreementName: string
sido: string
customerName: string
ssidUseCount: number | null
contractNumber: string
minusCategory: string
giftType: string
giftPlace: string
giftStatus: string
progressMemo: string
cost: number
event: number
agreement: number
local: number
gift: number
voc: number
etc: number
total: number
prevCost: number
prevEvent: number
prevAgreement: number
prevLocal: number
prevGift: number
prevVoc: number
prevEtc: number
prevTotal: number
negateOnTransfer: boolean
}
type DetailResponse = {
branch?: { branchId: string; officeName: string; name: string; phone: string; tel: string }
center?: { centerId: string; name: string; manager: string; tel: string; phone: string }
plus: { content: PreparedRow[]; page: number; totalPages: number; totalElements: number }
minus: { content: PreparedRow[]; page: number; totalPages: number; totalElements: number }
}
type FeeField = 'cost' | 'event' | 'agreement' | 'local' | 'gift' | 'voc' | 'etc'
type RowEdit = {
memo: string
cost: number
event: number
agreement: number
local: number
gift: number
voc: number
etc: number
}
type FeeSuggestion = {
orderId?: number
orderCenterId?: number
receptionId?: number
cost: number
event: number
agreement: number
local: number
gift: number
voc: number
etc: number
total: number
}
function defaultFilters() {
return {
orderDateStart: '2010-01-01',
orderDateEnd: today(),
dateType: 'order_date' as 'order_date' | 'opended',
perPage: 50,
sorting: 'order_date',
orderBy: 'desc',
productType: [] as string[],
carrier: [] as string[],
customerSido: [] as string[],
customerName: '',
contractNumber: '',
}
}
type FilterState = ReturnType<typeof defaultFilters>
const FEE_LABELS: Record<FeeField, string> = {
cost: '단가',
event: '이벤트적용',
agreement: '약정적용',
local: '지역적용',
gift: '사은품차감',
voc: 'VOC차감',
etc: '추가정산',
}
function quickRanges() {
const now = dayjs()
const d = (x: dayjs.Dayjs) => x.format('YYYY-MM-DD')
const dow = now.day()
return [
{ label: '금일', start: d(now), end: d(now) },
{ label: '금주', start: d(now.subtract(dow, 'day')), end: d(now.add(6 - dow, 'day')) },
{ label: '전주', start: d(now.subtract(dow + 7, 'day')), end: d(now.subtract(dow + 1, 'day')) },
{ label: '금월', start: d(now.startOf('month')), end: d(now.endOf('month')) },
{ label: '전월', start: d(now.subtract(1, 'month').startOf('month')), end: d(now.subtract(1, 'month').endOf('month')) },
{ label: '금년', start: d(now.startOf('year')), end: d(now.endOf('year')) },
{ label: '전년', start: d(now.subtract(1, 'year').startOf('year')), end: d(now.subtract(1, 'year').endOf('year')) },
]
}
/** 가정산 상세 — billing_react PreparedDetailEditor 이식 */
export default function PreparedDetailEditor({
kind, row, onDone,
}: {
kind: Kind
row: Summary
onDone: () => void
}) {
const base = kind === 'branch'
? `${apiPrefix()}/homes/calculate`
: `${apiPrefix()}/homes/center-calculate`
const qc = useQueryClient()
const [filters, setFilters] = useState<FilterState>(defaultFilters)
const [applied, setApplied] = useState<FilterState>(defaultFilters)
const [page, setPage] = useState(0)
const [page2, setPage2] = useState(0)
const [selected, setSelected] = useState<Set<number>>(new Set())
const [edits, setEdits] = useState<Record<number, RowEdit>>({})
const applyFilter = (patch: Partial<FilterState>) => {
setFilters((prev) => {
const next = { ...prev, ...patch }
setApplied(next)
return next
})
setPage(0)
setPage2(0)
}
const params = useMemo(() => ({
entity: row.entityKey,
orderDateStart: applied.orderDateStart,
orderDateEnd: applied.orderDateEnd,
dateType: applied.dateType,
page,
page2,
perPage: applied.perPage,
sorting: applied.sorting,
orderBy: applied.orderBy,
customerName: applied.customerName.trim() || undefined,
contractNumber: applied.contractNumber.trim() || undefined,
customerSido: applied.customerSido.length ? applied.customerSido : undefined,
productType: applied.productType.length ? applied.productType : undefined,
carrier: applied.carrier.length ? applied.carrier : undefined,
}), [applied, page, page2, row.entityKey])
const { data, isLoading, refetch } = useQuery({
queryKey: ['calc', kind, 'prepared-detail', row.entityKey, params],
queryFn: async () => {
const { data } = await client.get<ApiResponse<DetailResponse>>(`${base}/prepared/detail`, { params })
return unwrap(data)
},
})
const { data: suggestions } = useQuery({
queryKey: ['calc', kind, 'suggest', row.entityKey],
queryFn: async () => {
const { data } = await client.get<ApiResponse<FeeSuggestion[]>>(`${base}/suggest`, {
params: { entity: row.entityKey },
})
return unwrap(data) || []
},
})
const suggestById = useMemo(() => {
const m = new Map<number, FeeSuggestion>()
for (const s of suggestions ?? []) {
const id = kind === 'branch' ? (s.orderId ?? s.receptionId) : (s.orderCenterId ?? s.receptionId)
if (id != null) m.set(id, s)
}
return m
}, [suggestions, kind])
const carriers = useMemo(() => {
const set = new Set<string>()
for (const r of [...(data?.plus.content ?? []), ...(data?.minus.content ?? [])]) {
if (r.ispName) set.add(r.ispName)
}
return [...set].sort()
}, [data])
const initEdit = (r: PreparedRow): RowEdit => {
const existing = edits[r.orderId]
if (existing) return existing
const s = suggestById.get(r.orderId)
return {
memo: '',
cost: s?.cost ?? r.cost,
event: s?.event ?? r.event,
agreement: s?.agreement ?? r.agreement,
local: s?.local ?? r.local,
gift: s?.gift ?? r.gift,
voc: s?.voc ?? r.voc,
etc: s?.etc ?? r.etc,
}
}
useEffect(() => {
if (!suggestions?.length || !data) return
setEdits((prev) => {
const next = { ...prev }
let changed = false
for (const r of [...data.plus.content, ...data.minus.content]) {
if (prev[r.orderId]) continue
const s = suggestById.get(r.orderId)
if (!s) continue
next[r.orderId] = {
memo: '',
cost: s.cost, event: s.event, agreement: s.agreement, local: s.local,
gift: s.gift, voc: s.voc, etc: s.etc,
}
changed = true
}
return changed ? next : prev
})
}, [suggestions, data, suggestById])
const totalOf = (e: RowEdit) => e.cost + e.event + e.agreement + e.local + e.gift + e.voc + e.etc
const patch = (orderId: number, field: keyof RowEdit, value: string | number) => {
setEdits((prev) => {
const all = [...(data?.plus.content ?? []), ...(data?.minus.content ?? [])]
const base = prev[orderId] ?? initEdit(all.find((x) => x.orderId === orderId)!)
return { ...prev, [orderId]: { ...base, [field]: value } }
})
}
const toggle = (id: number) => setSelected((p) => {
const n = new Set(p)
if (n.has(id)) n.delete(id)
else n.add(id)
return n
})
const submit = useMutation({
mutationFn: async () => {
const ids = [...selected]
if (!ids.length) throw new Error('리스트를 선택하세요.')
const all = [...(data?.plus.content ?? []), ...(data?.minus.content ?? [])]
const lines = ids.map((id) => {
const r = all.find((x) => x.orderId === id)!
const e = edits[id] ?? initEdit(r)
return {
receptionId: id,
cost: e.cost, event: e.event, agreement: e.agreement, local: e.local,
gift: e.gift, voc: e.voc, etc: e.etc, memo: e.memo,
}
})
const { data: res } = await client.post(`${base}/toss-waiting`, { entity: row.entityKey, lines })
return unwrap(res)
},
onSuccess: () => {
message.success('정산대기로 등록되었습니다.')
setSelected(new Set())
qc.invalidateQueries({ queryKey: ['calc', kind] })
onDone()
},
onError: (e: Error) => message.error(e.message),
})
const cancelOne = useMutation({
mutationFn: async (orderId: number) => {
const { data: res } = await client.post(`${base}/prepared/cancel`, {
receptionIds: [orderId],
orderIds: [orderId],
orderCenterIds: [orderId],
})
return unwrap(res)
},
onSuccess: () => {
message.success('가정산을 취소했습니다.')
refetch()
qc.invalidateQueries({ queryKey: ['calc'] })
},
onError: (e: Error) => message.error(e.message),
})
const cancelMany = useMutation({
mutationFn: async () => {
const ids = [...selected]
if (!ids.length) throw new Error('취소할 가정산 리스트를 선택하세요.')
const { data: res } = await client.post(`${base}/prepared/cancel`, {
receptionIds: ids,
orderIds: ids,
orderCenterIds: ids,
})
return unwrap(res)
},
onSuccess: () => {
message.success('선택한 가정산을 취소했습니다.')
setSelected(new Set())
refetch()
qc.invalidateQueries({ queryKey: ['calc'] })
},
onError: (e: Error) => message.error(e.message),
})
const search = () => { setApplied({ ...filters }); setPage(0); setPage2(0) }
const resetSearch = () => {
const init = defaultFilters()
setFilters(init)
setApplied(init)
setPage(0)
setPage2(0)
}
const toggleSort = (col: string) => {
setFilters((f) => {
const orderBy = f.sorting === col && f.orderBy === 'desc' ? 'asc' : 'desc'
const next = { ...f, sorting: col, orderBy }
setApplied(next)
return next
})
setPage(0)
setPage2(0)
}
const sortMark = (col: string) => {
if (applied.sorting !== col) return '↕'
return applied.orderBy === 'asc' ? '▲' : '▼'
}
const feeFields: FeeField[] = ['cost', 'event', 'agreement', 'local', 'gift', 'voc', 'etc']
const ptLabel = (code: string) => PRODUCT_TYPE[code] || code || ''
const plusColumns = [
{
title: (
<Checkbox
onChange={(e) => {
const ids = (data?.plus.content ?? []).map((r) => r.orderId)
setSelected(e.target.checked
? new Set([...selected, ...ids])
: new Set([...selected].filter((id) => !ids.includes(id))))
}}
/>
),
width: 44,
fixed: 'left' as const,
render: (_: unknown, r: PreparedRow) => (
<Checkbox checked={selected.has(r.orderId)} onChange={() => toggle(r.orderId)} />
),
},
{
title: '번호',
width: 70,
render: (_: unknown, r: PreparedRow, idx: number) => (
<div className="center">
<div>{r.orderId.toLocaleString()}</div>
<div className="muted">({idx + 1 + page * applied.perPage})</div>
</div>
),
},
{
title: <button type="button" className="linkish" onClick={() => toggleSort('order_date')}>{sortMark('order_date')}접수일</button>,
width: 100,
render: (_: unknown, r: PreparedRow) => r.orderDate.replace(' ', '\n'),
},
{
title: <button type="button" className="linkish" onClick={() => toggleSort('opended')}>{sortMark('opended')}개통일</button>,
width: 100,
dataIndex: 'opened',
},
{
title: <button type="button" className="linkish" onClick={() => toggleSort('center')}>{sortMark('center')}입력처</button>,
width: 90,
dataIndex: 'centerName',
ellipsis: true,
},
{
title: <button type="button" className="linkish" onClick={() => toggleSort('isp')}>{sortMark('isp')}통신사</button>,
width: 80,
dataIndex: 'ispName',
},
{
title: <button type="button" className="linkish" onClick={() => toggleSort('product_type')}>{sortMark('product_type')}상품종류</button>,
width: 80,
render: (_: unknown, r: PreparedRow) => ptLabel(r.productType),
},
{
title: <button type="button" className="linkish" onClick={() => toggleSort('product')}>{sortMark('product')}상품명</button>,
width: 120,
dataIndex: 'productName',
ellipsis: true,
},
{ title: '이벤트', width: 80, dataIndex: 'eventNames', ellipsis: true },
{
title: <button type="button" className="linkish" onClick={() => toggleSort('agreement')}>{sortMark('agreement')}약정</button>,
width: 80,
dataIndex: 'agreementName',
},
{
title: <button type="button" className="linkish" onClick={() => toggleSort('sido')}>{sortMark('sido')}지역</button>,
width: 60,
dataIndex: 'sido',
},
{
title: <button type="button" className="linkish" onClick={() => toggleSort('name')}>{sortMark('name')}이름</button>,
width: 90,
render: (_: unknown, r: PreparedRow) => (
<div>
<div>{r.customerName}</div>
{r.ssidUseCount != null && r.ssidUseCount > 1 && <div style={{ color: '#c00' }}>({r.ssidUseCount})</div>}
</div>
),
},
{
title: <button type="button" className="linkish" onClick={() => toggleSort('contract_number')}>{sortMark('contract_number')}계약번호</button>,
width: 110,
dataIndex: 'contractNumber',
},
{
title: '추가메모',
width: 90,
render: (_: unknown, r: PreparedRow) => {
const e = edits[r.orderId] ?? initEdit(r)
return <Input size="small" value={e.memo} onChange={(ev) => patch(r.orderId, 'memo', ev.target.value)} />
},
},
...feeFields.map((f) => ({
title: FEE_LABELS[f],
width: 88,
align: 'right' as const,
render: (_: unknown, r: PreparedRow) => {
const e = edits[r.orderId] ?? initEdit(r)
return (
<InputNumber
{...moneyInputProps}
className="calc-money-input"
value={e[f]}
onChange={(v) => patch(r.orderId, f, Number(v || 0))}
/>
)
},
})),
{
title: '최종정산금액',
width: 110,
align: 'right' as const,
render: (_: unknown, r: PreparedRow) => {
const e = edits[r.orderId] ?? initEdit(r)
return <b>{won(totalOf(e))}</b>
},
},
{
title: '취소',
width: 70,
fixed: 'right' as const,
align: 'center' as const,
render: (_: unknown, r: PreparedRow) => (
<Button
size="small"
type="primary"
danger
className="branch-row-btn"
onClick={(e) => {
e.stopPropagation()
if (window.confirm('가정산을 취소합니다.')) cancelOne.mutate(r.orderId)
}}
>
취소
</Button>
),
},
{
title: '수수료현황',
width: 100,
fixed: 'right' as const,
align: 'center' as const,
render: (_: unknown, r: PreparedRow) => (
<Button
size="small"
type="primary"
className="branch-row-btn fee-status-row-btn"
onClick={(e) => {
e.stopPropagation()
void openFeeOrderPopup(r.orderId, kind)
}}
>
수수료현황
</Button>
),
},
]
const minusColumns = [
{
title: (
<Checkbox
onChange={(e) => {
const ids = (data?.minus.content ?? []).map((r) => r.orderId)
setSelected(e.target.checked
? new Set([...selected, ...ids])
: new Set([...selected].filter((id) => !ids.includes(id))))
}}
/>
),
width: 44,
render: (_: unknown, r: PreparedRow) => (
<Checkbox checked={selected.has(r.orderId)} onChange={() => toggle(r.orderId)} />
),
},
{
title: '번호',
width: 70,
render: (_: unknown, r: PreparedRow, idx: number) => (
<div>
<div>{r.orderId.toLocaleString()}</div>
<div className="muted">({idx + 1 + page2 * applied.perPage})</div>
</div>
),
},
{ title: '접수일', width: 100, render: (_: unknown, r: PreparedRow) => r.orderDate.replace(' ', '\n') },
{ title: '개통일', width: 100, dataIndex: 'opened' },
{ title: '입력처', width: 90, dataIndex: 'centerName' },
{
title: '통신사/상품',
width: 120,
render: (_: unknown, r: PreparedRow) => (
<div><div>{r.ispName}</div><div>{r.productName}</div></div>
),
},
{
title: '고객/약정',
width: 110,
render: (_: unknown, r: PreparedRow) => (
<div>
<div>{r.customerName}</div>
<div>{r.agreementName}</div>
</div>
),
},
{
title: '환수구분/계약번호',
width: 130,
render: (_: unknown, r: PreparedRow) => (
<div>
<div>{r.minusCategory || progressLabel(r.status)}</div>
<div>{r.contractNumber}</div>
</div>
),
},
{
title: '사은품',
width: 100,
render: (_: unknown, r: PreparedRow) => (
<div>
<div>{r.giftType}</div>
<div>{GIFT_PLACE[r.giftPlace] || r.giftPlace}</div>
</div>
),
},
{
title: '내역',
width: 110,
render: (_: unknown, r: PreparedRow) => (
<div>
<div title={r.progressMemo}>{r.progressMemo}</div>
<div>{GIFT_STATUS[r.giftStatus] || r.giftStatus}</div>
</div>
),
},
{
title: '추가메모',
width: 90,
render: (_: unknown, r: PreparedRow) => {
const e = edits[r.orderId] ?? initEdit(r)
return <Input size="small" value={e.memo} onChange={(ev) => patch(r.orderId, 'memo', ev.target.value)} />
},
},
{
title: '구분',
width: 70,
render: () => <div><div>이전정산</div><div>환수금액</div></div>,
},
...feeFields.map((f) => ({
title: FEE_LABELS[f].replace('적용', '').replace('차감', ''),
width: 88,
align: 'right' as const,
render: (_: unknown, r: PreparedRow) => {
const e = edits[r.orderId] ?? initEdit(r)
const prevMap: Record<FeeField, number> = {
cost: r.prevCost, event: r.prevEvent, agreement: r.prevAgreement, local: r.prevLocal,
gift: r.prevGift, voc: r.prevVoc, etc: r.prevEtc,
}
return (
<div style={{ textAlign: 'right' }}>
<InputNumber
{...moneyInputProps}
value={prevMap[f]}
disabled
style={{ ...moneyInputProps.style, marginBottom: 2 }}
/>
<InputNumber
{...moneyInputProps}
value={e[f]}
onChange={(v) => patch(r.orderId, f, Number(v || 0))}
/>
</div>
)
},
})),
{
title: '최종환수',
width: 110,
align: 'right' as const,
render: (_: unknown, r: PreparedRow) => {
const e = edits[r.orderId] ?? initEdit(r)
return (
<div style={{ textAlign: 'right' }}>
<div>
<InputNumber
{...moneyInputProps}
value={r.prevTotal}
disabled
style={{ ...moneyInputProps.style, width: 90 }}
/>
</div>
<div><b>{won(totalOf(e))}</b></div>
</div>
)
},
},
{
title: '수수료현황',
width: 100,
fixed: 'right' as const,
align: 'center' as const,
render: (_: unknown, r: PreparedRow) => (
<Button
size="small"
type="primary"
className="branch-row-btn fee-status-row-btn"
onClick={(e) => {
e.stopPropagation()
void openFeeOrderPopup(r.orderId, kind)
}}
>
수수료현황
</Button>
),
},
]
return (
<div className="prepared-detail">
<p className="prepared-detail-subtitle"><b>{row.title}</b> — 가정산 상세페이지</p>
{data?.center && (
<table className="prg-table" style={{ marginTop: 8 }}>
<tbody>
<tr>
<th>입력처명</th><td>{data.center.name}</td>
<th>담당자</th><td>{data.center.manager}</td>
<th>연락처</th><td>{data.center.phone}</td>
</tr>
</tbody>
</table>
)}
{data?.branch && (
<table className="prg-table" style={{ marginTop: 8 }}>
<tbody>
<tr>
<th>영업점명</th><td>{data.branch.officeName}</td>
<th>이름</th><td>{data.branch.name}</td>
<th>휴대전화</th><td>{data.branch.phone}</td>
<th>일반전화</th><td>{data.branch.tel}</td>
</tr>
</tbody>
</table>
)}
<Space wrap style={{ marginTop: 12 }} size={8}>
<Radio.Group
value={filters.dateType}
onChange={(e) => setFilters((f) => ({ ...f, dateType: e.target.value }))}
optionType="button"
options={[
{ value: 'order_date', label: '접수일' },
{ value: 'opended', label: '개통일' },
]}
/>
<DatePicker value={dayjs(filters.orderDateStart)} onChange={(d) => setFilters((f) => ({ ...f, orderDateStart: d?.format('YYYY-MM-DD') || '2010-01-01' }))} />
<span>~</span>
<DatePicker value={dayjs(filters.orderDateEnd)} onChange={(d) => setFilters((f) => ({ ...f, orderDateEnd: d?.format('YYYY-MM-DD') || today() }))} />
{quickRanges().map((q) => (
<Button key={q.label} size="small" onClick={() => {
const next = { ...filters, orderDateStart: q.start, orderDateEnd: q.end }
setFilters(next)
setApplied(next)
setPage(0)
setPage2(0)
}}>{q.label}</Button>
))}
<span>한 페이지당 <InputNumber min={10} value={filters.perPage} onChange={(v) => setFilters((f) => ({ ...f, perPage: Math.max(10, Number(v || 50)) }))} style={{ width: 64 }} /></span>
<Button type="primary" onClick={search}>검색</Button>
<Button onClick={resetSearch}>검색취소</Button>
</Space>
<Space wrap style={{ marginTop: 8 }} size={8}>
<Select
mode="multiple"
allowClear
placeholder="통신사"
style={{ minWidth: 140 }}
value={filters.carrier}
onChange={(v) => applyFilter({ carrier: v })}
options={carriers.map((c) => ({ value: c, label: c }))}
/>
<Select
mode="multiple"
allowClear
placeholder="상품종류"
style={{ minWidth: 140 }}
value={filters.productType}
onChange={(v) => applyFilter({ productType: v })}
options={Object.entries(PRODUCT_TYPE).map(([k, v]) => ({ value: k, label: v }))}
/>
<Select
mode="multiple"
allowClear
placeholder="지역"
style={{ minWidth: 140 }}
value={filters.customerSido}
onChange={(v) => applyFilter({ customerSido: v })}
options={SIDO_LIST.map((s) => ({ value: s, label: s }))}
/>
<Input
placeholder="고객명"
value={filters.customerName}
onChange={(e) => setFilters((f) => ({ ...f, customerName: e.target.value }))}
onPressEnter={search}
style={{ width: 120 }}
/>
<Input
placeholder="개통번호"
value={filters.contractNumber}
onChange={(e) => setFilters((f) => ({ ...f, contractNumber: e.target.value }))}
onPressEnter={search}
style={{ width: 140 }}
/>
</Space>
<h4 style={{ marginTop: 16, fontSize: 14 }}>
정산 리스트
{data ? <span className="muted"> ({data.plus.totalElements})</span> : null}
</h4>
<Table
className="calc-prepared-table"
rowKey="orderId"
size="small"
loading={isLoading}
scroll={{ x: 2200 }}
pagination={data && data.plus.totalPages > 1 ? {
current: page + 1,
pageSize: applied.perPage,
total: data.plus.totalElements,
onChange: (p) => setPage(p - 1),
showSizeChanger: false,
} : false}
dataSource={data?.plus.content || []}
columns={plusColumns}
rowClassName={(r) => (selected.has(r.orderId) ? 'calc-row-selected' : '')}
onRow={(r) => ({
style: { cursor: 'pointer' },
onClick: (e) => {
if (!isFeeRowClickTarget(e.target)) return
void openFeeOrderPopup(r.orderId, kind)
},
})}
/>
<h4 style={{ marginTop: 20, fontSize: 14 }}>
해지 리스트
{data ? <span className="muted"> ({data.minus.totalElements})</span> : null}
</h4>
<Table
className="calc-prepared-table"
rowKey="orderId"
size="small"
loading={isLoading}
scroll={{ x: 2000 }}
pagination={data && data.minus.totalPages > 1 ? {
current: page2 + 1,
pageSize: applied.perPage,
total: data.minus.totalElements,
onChange: (p) => setPage2(p - 1),
showSizeChanger: false,
} : false}
dataSource={data?.minus.content || []}
columns={minusColumns}
rowClassName={(r) => (selected.has(r.orderId) ? 'calc-row-selected' : '')}
onRow={(r) => ({
style: { cursor: 'pointer' },
onClick: (e) => {
if (!isFeeRowClickTarget(e.target)) return
void openFeeOrderPopup(r.orderId, kind)
},
})}
/>
<Space style={{ marginTop: 16 }} size={8}>
<Button type="primary" loading={submit.isPending} onClick={() => submit.mutate()}>
정산대기로 보내기
</Button>
<Button
type="primary"
danger
className="branch-row-btn"
loading={cancelMany.isPending}
onClick={() => {
if (window.confirm('선택한 가정산을 취소합니다.')) cancelMany.mutate()
}}
>
가정산 일괄 취소
</Button>
<span className="muted" style={{ fontSize: 12 }}>선택 {selected.size}건 · 행 클릭: 수수료 현황</span>
</Space>
</div>
)
}
+1 -1
View File
@@ -325,7 +325,7 @@ export default function ProductHomePage() {
)}
<div className="product-modal-footer-right">
<Button type="primary" htmlType="submit" loading={save.isPending}>
{editing ? '수정합니다' : '등록'}
{editing ? '수정' : '등록'}
</Button>
<Button onClick={() => setOpen(false)}>취소</Button>
</div>
@@ -325,7 +325,7 @@ export default function ProductInternetPage() {
)}
<div className="product-modal-footer-right">
<Button type="primary" htmlType="submit" loading={save.isPending}>
{editing ? '수정합니다' : '등록'}
{editing ? '수정' : '등록'}
</Button>
<Button onClick={() => setOpen(false)}>취소</Button>
</div>
+47 -50
View File
@@ -18,29 +18,18 @@ import ReceptionProgressModal from '../../components/ReceptionProgressModal'
import { DownOutlined } from '@ant-design/icons'
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
import { options } from './homesShared'
/** 진행현황 배지 색상 클래스 */
function progressBadgeClass(status?: string) {
const s = (status || '').trim()
if (s.startsWith('1.') || s.includes('접수완료')) return 'badge badge-ps-1'
if (s.startsWith('2.') || s.includes('고객통화')) return 'badge badge-ps-2'
if (s.startsWith('3.') || s.includes('서류검토')) return 'badge badge-ps-3'
if (s.startsWith('4.') || s.includes('개통완료')) return 'badge badge-ps-4'
if (s.startsWith('5.') || s.includes('사은품지급')) return 'badge badge-ps-5'
if (s.startsWith('6.') || s.includes('정산완료')) return 'badge badge-ps-6'
if (s.startsWith('9.') || s.includes('취소')) return 'badge badge-ps-9'
return 'badge'
}
/** 사은품현황 배지 색상 클래스 */
function giftBadgeClass(status?: string) {
const s = (status || '').trim()
if (s === '미지급') return 'badge badge-gs-none'
if (s === '지급대기') return 'badge badge-gs-wait'
if (s === '지급완료') return 'badge badge-gs-done'
if (s === '반송') return 'badge badge-gs-return'
return 'badge'
}
import {
progressBadgeClass,
giftBadgeClass,
progressLabel,
giftLabel,
centerLabel,
giftPlaceLabel as placeLabel,
statusOptions,
PROGRESS_STATUS,
GIFT_STATUS,
CENTER_STATUS,
} from './receptionStatuses'
type ReceptionRow = {
id: number
@@ -64,9 +53,12 @@ type ReceptionRow = {
region?: string
sido?: string
progressStatus?: string
progressStatusLabel?: string
status?: string
settleStatus?: string
settleStatusLabel?: string
centerStatus?: string
centerStatusLabel?: string
centerName?: string
openDate?: string
opened?: string
@@ -75,6 +67,7 @@ type ReceptionRow = {
giftDate?: string
giftSended?: string
giftStatus?: string
giftStatusLabel?: string
giftPlace?: string
giftAmount?: number
agreementName?: string
@@ -91,6 +84,9 @@ type ReceptionResult = {
progressStatuses?: string[]
settleStatuses?: string[]
giftStatuses?: string[]
progressStatusLabels?: Record<string, string>
giftStatusLabels?: Record<string, string>
settleStatusLabels?: Record<string, string>
carriers?: string[]
branches?: string[]
counselors?: string[]
@@ -105,13 +101,6 @@ const SIDO_LIST = [
'경북', '대구', '경남', '울산', '부산', '제주',
]
const GIFT_PLACE_LABEL: Record<string, string> = {
b01: '본사요청',
b02: '자체발송',
본사: '본사발송',
영업점: '영업점발송',
}
const initialFilters = () => ({
page: 0,
size: 50,
@@ -144,10 +133,7 @@ const initialFilters = () => ({
const num = (n?: number | null) => (n == null ? '' : Number(n).toLocaleString())
const giftPlaceLabel = (v?: string) => {
if (!v) return ''
return GIFT_PLACE_LABEL[v] || v
}
const giftPlaceLabel = (v?: string) => placeLabel(v)
const splitDt = (v?: string) => {
if (!v) return ['', '']
@@ -221,11 +207,17 @@ export default function ReceptionListPage() {
setSelected([])
}
const progressStatuses = query.data?.progressStatuses || []
const settleStatuses = query.data?.settleStatuses || []
const giftStatuses = query.data?.giftStatuses || []
const progressStatuses = query.data?.progressStatuses || Object.keys(PROGRESS_STATUS)
const giftStatuses = query.data?.giftStatuses || Object.keys(GIFT_STATUS)
const progressLabels = query.data?.progressStatusLabels || PROGRESS_STATUS
const giftLabels = query.data?.giftStatusLabels || GIFT_STATUS
const settleLabels = query.data?.settleStatusLabels || CENTER_STATUS
const list = query.data?.list || []
const progressSelectOpts = statusOptions(progressLabels as Record<string, string>)
const giftSelectOpts = statusOptions(giftLabels as Record<string, string>)
const settleSelectOpts = statusOptions(settleLabels as Record<string, string>)
const setD = (patch: Partial<ReturnType<typeof initialFilters>>) => setDraft((p) => ({ ...p, ...patch }))
const applyF = (patch: Partial<ReturnType<typeof initialFilters>>) => {
const next = { ...draft, ...patch, page: 0 }
@@ -416,8 +408,6 @@ export default function ReceptionListPage() {
<div className="toolbar order-top-buttons">
<Button type="primary" onClick={search}>검색</Button>
<Button onClick={reset}>검색 초기화</Button>
<Button onClick={downloadExcel}>Excel 다운로드</Button>
<Button onClick={downloadExcel}>사은품 Excel 다운로드</Button>
</div>
<div className="order-filter">
@@ -456,6 +446,13 @@ export default function ReceptionListPage() {
onChange={(e) => setD({ size: Number(e.target.value) || 50 })}
/>
</label>
<div className="order-attach-buttons">
<Button onClick={downloadExcel}>Excel 다운로드</Button>
<Button onClick={downloadExcel}>사은품 Excel 다운로드</Button>
</div>
</div>
<div className="order-result-row">
<span className="order-result muted">검색 결과: 총 {(query.data?.total ?? 0).toLocaleString()} 건</span>
</div>
@@ -532,7 +529,7 @@ export default function ReceptionListPage() {
placeholder="입력처"
value={draft.settleStatus}
onChange={(v) => applyF({ settleStatus: v })}
options={options(settleStatuses)}
options={settleSelectOpts}
style={{ width: '100%' }}
/>
</th>
@@ -604,7 +601,7 @@ export default function ReceptionListPage() {
placeholder="진행현황"
value={draft.progressStatus}
onChange={(v) => applyF({ progressStatus: v })}
options={options(progressStatuses)}
options={progressSelectOpts}
style={{ width: '100%' }}
/>
</th>
@@ -639,7 +636,7 @@ export default function ReceptionListPage() {
placeholder="사은품진행"
value={draft.giftStatus}
onChange={(v) => applyF({ giftStatus: v })}
options={options(giftStatuses)}
options={giftSelectOpts}
style={{ width: '100%' }}
/>
</th>
@@ -701,7 +698,7 @@ export default function ReceptionListPage() {
</td>
<td className="col-status floating-cell">
<p className="c-green">{o.employeeName || o.counselor || ''}</p>
<p><span className={progressBadgeClass(o.status || o.progressStatus)}>{o.status || o.progressStatus || ''}</span></p>
<p><span className={progressBadgeClass(o.progressStatus || o.status)}>{o.progressStatusLabel || progressLabel(o.progressStatus || o.status) || ''}</span></p>
<div className="floating-layer">
<p>개통일: {o.opened || o.openDate || '-'}</p>
<p>해지일: {o.canceled || '-'}</p>
@@ -717,13 +714,13 @@ export default function ReceptionListPage() {
<p className="fs11">{o.giftSended || o.giftDate || ''}</p>
</td>
<td className="col-gift">
<p><span className={giftBadgeClass(o.giftStatus)}>{o.giftStatus || ''}</span></p>
<p><span className={giftBadgeClass(o.giftStatus)}>{o.giftStatusLabel || giftLabel(o.giftStatus) || ''}</span></p>
<p>{num(giftAmt)}</p>
</td>
<td className="col-cstatus floating-cell">
{o.centerStatus || o.settleStatus || ''}
{o.centerStatusLabel || centerLabel(o.centerStatus || o.settleStatus) || ''}
<div className="floating-layer right">
<p>정산상태: {o.settleStatus || '-'}</p>
<p>입력처정산: {o.centerStatusLabel || centerLabel(o.settleStatus) || '-'}</p>
<p>개통일: {o.opened || o.openDate || '-'}</p>
<p>해지일: {o.canceled || o.cancelDate || '-'}</p>
</div>
@@ -788,13 +785,13 @@ export default function ReceptionListPage() {
<Form.Item name="customerName" label="고객명" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="phone" label="연락처"><Input /></Form.Item>
<Form.Item name="progressStatus" label="진행상황">
<Select options={options(progressStatuses)} />
<Select options={progressSelectOpts} />
</Form.Item>
<Form.Item name="settleStatus" label="정산상태">
<Select options={options(settleStatuses)} />
<Form.Item name="settleStatus" label="입력처정산">
<Select options={settleSelectOpts} />
</Form.Item>
<Form.Item name="giftStatus" label="사은품진행">
<Select options={options(giftStatuses)} />
<Select options={giftSelectOpts} />
</Form.Item>
</div>
<div className="balance-modal-footer">
@@ -416,7 +416,7 @@ export default function SettingAgencyPage() {
loading={saveMut.isPending}
icon={<EditOutlined />}
>
{editing ? '수정합니다' : '등록'}
{editing ? '수정' : '등록'}
</Button>
<Button
onClick={() => {
@@ -0,0 +1,220 @@
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Button, Card, Input, Select, Space, message } from 'antd'
import { SettingOutlined } from '@ant-design/icons'
import CareTable from '../../components/CareTable'
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
type Board = {
id: number
name?: string
listLevel: number
readLevel: number
writeLevel: number
seq?: number
isOn?: number | boolean
active?: boolean
}
const LEVELS = [
{ value: 1, label: '영업점(1)' },
{ value: 5, label: '센터(5)' },
{ value: 9, label: '관리자(9)' },
{ value: 10, label: '최고관리자(10)' },
]
const empty = { name: '', listLevel: 1, readLevel: 1, writeLevel: 9 }
const lvlLabel = (v: number) => LEVELS.find((x) => x.value === v)?.label || String(v)
export default function SettingBoardAdminPage() {
const qc = useQueryClient()
const [form, setForm] = useState<{ id?: number; name: string; listLevel: number; readLevel: number; writeLevel: number }>(empty)
const query = useQuery({
queryKey: ['homes-boards'],
queryFn: async () => {
const { data } = await client.get<ApiResponse<Board[]>>(`${apiPrefix()}/homes/boards`)
return unwrap(data) || []
},
})
const refresh = () => qc.invalidateQueries({ queryKey: ['homes-boards'] })
const reset = () => setForm(empty)
const saveMut = useMutation({
mutationFn: async () => {
const body = {
name: form.name,
listLevel: form.listLevel,
readLevel: form.readLevel,
writeLevel: form.writeLevel,
}
if (form.id) {
const { data } = await client.put(`${apiPrefix()}/homes/boards/${form.id}`, body)
return unwrap(data as ApiResponse)
}
const { data } = await client.post(`${apiPrefix()}/homes/boards`, body)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success(form.id ? '수정했습니다.' : '추가했습니다.')
reset()
refresh()
},
onError: (e: Error) => message.error(e.message),
})
const removeMut = useMutation({
mutationFn: async (id: number) => {
const { data } = await client.delete(`${apiPrefix()}/homes/boards/${id}`)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success('삭제했습니다.')
refresh()
},
onError: (e: Error) => message.error(e.message),
})
const columns = useMemo(
() => [
{
title: '순서',
dataIndex: 'seq',
width: 70,
align: 'center' as const,
render: (v?: number) => v ?? '-',
},
{ title: '게시판명', dataIndex: 'name', ellipsis: true },
{
title: '목록권한',
dataIndex: 'listLevel',
width: 120,
render: (v: number) => lvlLabel(v),
},
{
title: '읽기권한',
dataIndex: 'readLevel',
width: 120,
render: (v: number) => lvlLabel(v),
},
{
title: '쓰기권한',
dataIndex: 'writeLevel',
width: 130,
render: (v: number) => lvlLabel(v),
},
{
title: '',
width: 140,
render: (_: unknown, row: Board) => (
<Space>
<Button
size="small"
type="primary"
className="branch-row-btn"
onClick={() =>
setForm({
id: row.id,
name: row.name || '',
listLevel: row.listLevel,
readLevel: row.readLevel,
writeLevel: row.writeLevel,
})
}
>
수정
</Button>
<Button
size="small"
danger
onClick={() => {
if (window.confirm('삭제하시겠습니까?')) removeMut.mutate(row.id)
}}
>
삭제
</Button>
</Space>
),
},
],
[removeMut],
)
return (
<div className="stock-page">
<div className="stock-title">
<div>
<h2>
<SettingOutlined className="product-title-icon" />
게시판관리
</h2>
<p>게시판 정의와 목록/읽기/쓰기 권한 레벨을 관리합니다.</p>
</div>
</div>
<Card size="small" style={{ marginBottom: 12 }}>
<Space wrap align="end">
<div>
<div style={{ fontSize: 11, color: 'var(--ant-color-text-secondary)', marginBottom: 4 }}>게시판명</div>
<Input
placeholder="게시판명"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
style={{ width: 180 }}
/>
</div>
<div>
<div style={{ fontSize: 11, color: 'var(--ant-color-text-secondary)', marginBottom: 4 }}>목록 권한</div>
<Select
style={{ width: 140 }}
value={form.listLevel}
onChange={(v) => setForm({ ...form, listLevel: v })}
options={LEVELS}
/>
</div>
<div>
<div style={{ fontSize: 11, color: 'var(--ant-color-text-secondary)', marginBottom: 4 }}>읽기 권한</div>
<Select
style={{ width: 140 }}
value={form.readLevel}
onChange={(v) => setForm({ ...form, readLevel: v })}
options={LEVELS}
/>
</div>
<div>
<div style={{ fontSize: 11, color: 'var(--ant-color-text-secondary)', marginBottom: 4 }}>쓰기 권한</div>
<Select
style={{ width: 150 }}
value={form.writeLevel}
onChange={(v) => setForm({ ...form, writeLevel: v })}
options={LEVELS}
/>
</div>
<Button
type="primary"
disabled={!form.name.trim()}
loading={saveMut.isPending}
onClick={() => saveMut.mutate()}
>
{form.id ? '수정' : '추가'}
</Button>
{form.id && <Button onClick={reset}>취소</Button>}
</Space>
</Card>
<Card size="small">
<CareTable
rowKey="id"
size="small"
loading={query.isLoading}
dataSource={query.data || []}
columns={columns}
pagination={false}
locale={{ emptyText: '등록된 게시판이 없습니다.' }}
/>
</Card>
</div>
)
}
@@ -0,0 +1,388 @@
import { useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Button, Card, Form, Input, Space, Typography, message } from 'antd'
import { SettingOutlined } from '@ant-design/icons'
import CareTable from '../../components/CareTable'
import DraggableModal from '../../components/DraggableModal'
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
type CenterRow = {
id: number
name?: string
tel?: string
phone?: string
manager?: string
seq?: number
isOn?: number | boolean
active?: boolean
}
type QdCenter = {
id: number
name?: string
authorized?: boolean
baseUrl?: string | null
}
const isActive = (row: { isOn?: number | boolean; active?: boolean }) =>
row.isOn === 1 || row.isOn === true || row.active === true
export default function SettingCenterPage() {
const qc = useQueryClient()
const [form] = Form.useForm()
const [editForm] = Form.useForm()
const [authForm] = Form.useForm()
const [editTarget, setEditTarget] = useState<CenterRow | null>(null)
const [authTarget, setAuthTarget] = useState<(QdCenter & { mode: 'auth' | 'reauth' }) | null>(null)
const query = useQuery({
queryKey: ['homes-input-centers'],
queryFn: async () => {
const { data } = await client.get<ApiResponse<CenterRow[]>>(`${apiPrefix()}/homes/input-centers`)
return unwrap(data) || []
},
})
const qdQuery = useQuery({
queryKey: ['homes-qd-centers'],
queryFn: async () => {
try {
const { data } = await client.get<ApiResponse<QdCenter[]>>(
`${apiPrefix()}/homes/quick-delivery/centers`,
)
return unwrap(data) || []
} catch {
return [] as QdCenter[]
}
},
})
const qdMap = useMemo(
() => new Map((qdQuery.data || []).map((c) => [c.id, c])),
[qdQuery.data],
)
const refresh = () => {
qc.invalidateQueries({ queryKey: ['homes-input-centers'] })
qc.invalidateQueries({ queryKey: ['homes-qd-centers'] })
}
const createMut = useMutation({
mutationFn: async (values: { name: string; tel?: string; manager?: string }) => {
const { data } = await client.post(`${apiPrefix()}/homes/input-centers`, values)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
form.resetFields()
message.success('추가했습니다.')
refresh()
},
onError: (e: Error) => message.error(e.message),
})
const saveMut = useMutation({
mutationFn: async (values: { name: string; tel?: string; manager?: string }) => {
const { data } = await client.put(
`${apiPrefix()}/homes/input-centers/${editTarget!.id}`,
values,
)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success('수정했습니다.')
setEditTarget(null)
refresh()
},
onError: (e: Error) => message.error(e.message),
})
const onoffMut = useMutation({
mutationFn: async (id: number) => {
const { data } = await client.post(`${apiPrefix()}/homes/input-centers/${id}/onoff`)
return unwrap(data as ApiResponse)
},
onSuccess: () => refresh(),
onError: (e: Error) => message.error(e.message),
})
const moveMut = useMutation({
mutationFn: async ({ id, dir }: { id: number; dir: 'up' | 'down' }) => {
const { data } = await client.post(`${apiPrefix()}/homes/input-centers/${id}/${dir}`)
return unwrap(data as ApiResponse)
},
onSuccess: () => refresh(),
onError: (e: Error) => message.error(e.message),
})
const removeMut = useMutation({
mutationFn: async (id: number) => {
const { data } = await client.delete(`${apiPrefix()}/homes/input-centers/${id}`)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success('삭제했습니다.')
refresh()
},
onError: (e: Error) => message.error(e.message),
})
const authMut = useMutation({
mutationFn: async (values: { userid: string; password: string; baseUrl?: string }) => {
const id = authTarget!.id
if (authTarget!.mode === 'reauth') {
const { data } = await client.post(
`${apiPrefix()}/homes/quick-delivery/center/${id}/reauth`,
{ userid: values.userid, password: values.password },
)
return unwrap(data as ApiResponse)
}
const { data } = await client.post(
`${apiPrefix()}/homes/quick-delivery/center/${id}/auth`,
values,
)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success('완료되었습니다.')
setAuthTarget(null)
authForm.resetFields()
refresh()
},
onError: (e: Error) => message.error(e.message),
})
const removeAuthMut = useMutation({
mutationFn: async (id: number) => {
const { data } = await client.delete(`${apiPrefix()}/homes/quick-delivery/center/${id}/auth`)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success('인증을 해제했습니다.')
refresh()
},
onError: (e: Error) => message.error(e.message),
})
const columns = useMemo(
() => [
{
title: '순서',
width: 90,
align: 'center' as const,
render: (_: unknown, row: CenterRow, idx: number) => (
<Space size={4}>
<Button size="small" disabled={idx === 0} onClick={() => moveMut.mutate({ id: row.id, dir: 'up' })}>
▲
</Button>
<Button
size="small"
disabled={idx === (query.data?.length ?? 0) - 1}
onClick={() => moveMut.mutate({ id: row.id, dir: 'down' })}
>
▼
</Button>
</Space>
),
},
{ title: '센터명', dataIndex: 'name', ellipsis: true },
{ title: '연락처', dataIndex: 'tel', width: 130, render: (v?: string) => v || '' },
{ title: '담당자', dataIndex: 'manager', width: 100, render: (v?: string) => v || '' },
{
title: '상태',
width: 80,
align: 'center' as const,
render: (_: unknown, row: CenterRow) => (
<Button
size="small"
type={isActive(row) ? 'primary' : 'default'}
className="branch-row-btn"
onClick={() => onoffMut.mutate(row.id)}
>
{isActive(row) ? '사용' : '중지'}
</Button>
),
},
{
title: '퀵전송',
width: 180,
render: (_: unknown, row: CenterRow) => {
const qd = qdMap.get(row.id)
return (
<Space size={4} wrap>
{qd?.authorized ? (
<Typography.Text type="success">인증됨</Typography.Text>
) : (
<Typography.Text type="secondary">미인증</Typography.Text>
)}
<Button
size="small"
onClick={() => {
setAuthTarget({
id: row.id,
name: row.name,
baseUrl: qd?.baseUrl,
authorized: qd?.authorized,
mode: qd?.authorized ? 'reauth' : 'auth',
})
authForm.setFieldsValue({
baseUrl: qd?.baseUrl || '',
userid: '',
password: '',
})
}}
>
{qd?.authorized ? '재인증' : '인증'}
</Button>
{qd?.authorized && (
<Button
size="small"
danger
onClick={() => {
if (window.confirm('인증을 해제하시겠습니까?')) removeAuthMut.mutate(row.id)
}}
>
해제
</Button>
)}
</Space>
)
},
},
{
title: '',
width: 220,
render: (_: unknown, row: CenterRow) => (
<Space size={4} wrap>
<Button
size="small"
type="primary"
className="branch-row-btn"
onClick={() => {
setEditTarget(row)
editForm.setFieldsValue({
name: row.name,
tel: row.tel,
manager: row.manager,
})
}}
>
수정
</Button>
<Link to={`/care/setting/fee?tab=center&targetId=${row.id}`}>
<Button size="small" type="primary" className="branch-row-btn">
수수료
</Button>
</Link>
<Button
size="small"
danger
onClick={() => {
if (window.confirm('삭제하시겠습니까?')) removeMut.mutate(row.id)
}}
>
삭제
</Button>
</Space>
),
},
],
[query.data, qdMap, moveMut, onoffMut, removeMut, removeAuthMut, editForm, authForm],
)
return (
<div className="stock-page">
<div className="stock-title">
<div>
<h2>
<SettingOutlined className="product-title-icon" />
입력처관리
</h2>
<p>센터(입력처)를 등록하고 퀵전송 인증·수수료를 관리합니다.</p>
</div>
</div>
<Card size="small" style={{ marginBottom: 12 }}>
<Form form={form} layout="inline" onFinish={(v) => createMut.mutate(v)}>
<Form.Item name="name" rules={[{ required: true, message: '센터명' }]}>
<Input placeholder="센터명" />
</Form.Item>
<Form.Item name="tel">
<Input placeholder="연락처" />
</Form.Item>
<Form.Item name="manager">
<Input placeholder="담당자" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={createMut.isPending}>
추가
</Button>
</Form.Item>
</Form>
</Card>
<Card size="small">
<CareTable
rowKey="id"
size="small"
loading={query.isLoading}
dataSource={query.data || []}
columns={columns}
pagination={false}
locale={{ emptyText: '등록된 입력처가 없습니다.' }}
scroll={{ x: 900 }}
/>
</Card>
<DraggableModal
title="입력처 수정"
open={!!editTarget}
onCancel={() => setEditTarget(null)}
onOk={() => editForm.submit()}
confirmLoading={saveMut.isPending}
destroyOnClose
width={420}
>
<Form form={editForm} layout="vertical" onFinish={(v) => saveMut.mutate(v)}>
<Form.Item name="name" label="센터명" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="tel" label="연락처">
<Input />
</Form.Item>
<Form.Item name="manager" label="담당자">
<Input />
</Form.Item>
</Form>
</DraggableModal>
<DraggableModal
title={`${authTarget?.name || ''} — ${authTarget?.mode === 'reauth' ? '재인증' : '인증'}`}
open={!!authTarget}
onCancel={() => setAuthTarget(null)}
onOk={() => authForm.submit()}
confirmLoading={authMut.isPending}
destroyOnClose
width={440}
>
<Form form={authForm} layout="vertical" onFinish={(v) => authMut.mutate(v)}>
{authTarget?.mode === 'auth' && (
<Form.Item
name="baseUrl"
label="상부점 기본 URL"
rules={[{ required: true, message: 'URL을 입력하세요.' }]}
>
<Input placeholder="https://upper.example.com" />
</Form.Item>
)}
<Form.Item name="userid" label="상부점 아이디" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="password" label="상부점 비밀번호" rules={[{ required: true }]}>
<Input.Password />
</Form.Item>
</Form>
</DraggableModal>
</div>
)
}
@@ -0,0 +1,302 @@
import { useEffect, useState, type ReactNode } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Button,
Card,
Checkbox,
Form,
Input,
Space,
Typography,
message,
} from 'antd'
import { SettingOutlined } from '@ant-design/icons'
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
const TOGGLES: [string, string][] = [
['order_disabled', '접수 등록 잠금(최근 2개월만)'],
['personal_info_conceal', '개인정보 숨김'],
['personal_info_conceal_in_order_list', '접수목록 개인정보 숨김'],
['exceldown_button_hide', '엑셀 다운로드 버튼 숨김'],
['gift_button_hide', '사은품 버튼 숨김'],
]
const chk = (v?: string) => v === 'true'
export default function SettingConfigurePage() {
const qc = useQueryClient()
const [config, setConfig] = useState<Record<string, string>>({})
const query = useQuery({
queryKey: ['homes-configure'],
queryFn: async () => {
const { data } = await client.get<ApiResponse<Record<string, string>>>(
`${apiPrefix()}/homes/configure`,
)
return unwrap(data) || {}
},
})
useEffect(() => {
if (query.data) setConfig(query.data)
}, [query.data])
const set = (key: string, value: string) => setConfig((c) => ({ ...c, [key]: value }))
const setChk = (key: string, checked: boolean) => set(key, checked ? 'true' : 'false')
const saveMut = useMutation({
mutationFn: async () => {
const { data } = await client.post(`${apiPrefix()}/homes/configure`, config)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success('저장되었습니다.')
qc.invalidateQueries({ queryKey: ['homes-configure'] })
},
onError: (e: Error) => message.error(e.message),
})
const toggleMut = useMutation({
mutationFn: async (key: string) => {
const { data } = await client.post(`${apiPrefix()}/homes/configure/toggle/${key}`)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['homes-configure'] })
},
onError: (e: Error) => message.error(e.message),
})
const field = (
label: string,
children: ReactNode,
hint?: string,
) => (
<Form.Item label={label} style={{ marginBottom: 16 }}>
{children}
{hint && (
<Typography.Paragraph type="secondary" style={{ margin: '4px 0 0', fontSize: 12 }}>
{hint}
</Typography.Paragraph>
)}
</Form.Item>
)
return (
<div className="stock-page">
<div className="stock-title">
<div>
<h2>
<SettingOutlined className="product-title-icon" />
기타 환경 설정
</h2>
<p>사이트·알림·IP·기능 토글 등 기타 설정을 관리합니다.</p>
</div>
<Space>
<Button type="primary" loading={saveMut.isPending} onClick={() => saveMut.mutate()}>
저장
</Button>
</Space>
</div>
<Card size="small" loading={query.isLoading} style={{ maxWidth: 900, marginBottom: 16 }}>
<Form layout="vertical">
{field(
'사이트명',
<Input
value={config.sitename || ''}
onChange={(e) => set('sitename', e.target.value)}
style={{ maxWidth: 320 }}
/>,
)}
{field(
'고객센터번호',
<Input
value={config.tel || ''}
onChange={(e) => set('tel', e.target.value)}
style={{ maxWidth: 220 }}
/>,
'로그인 페이지에 표시될 고객센터 번호입니다.',
)}
{field(
'FAX',
<Input
value={config.fax || ''}
onChange={(e) => set('fax', e.target.value)}
style={{ maxWidth: 220 }}
/>,
)}
{field(
'SMS 발신번호',
<Input
value={config.sms_callback || ''}
onChange={(e) => set('sms_callback', e.target.value)}
style={{ maxWidth: 400 }}
/>,
'SMS 발신번호는 등록 후 사용 가능합니다.',
)}
{field(
'엑셀 다운로드 이메일',
<Input
value={config.excel_down_email || ''}
onChange={(e) => set('excel_down_email', e.target.value)}
style={{ maxWidth: 280 }}
/>,
'설정 시 엑셀 생성 후 해당 주소로 파일이 발송됩니다.',
)}
{field(
'로고 경로',
<Input
value={config.logo_path || ''}
onChange={(e) => set('logo_path', e.target.value)}
placeholder="/path/to/logo.png"
style={{ maxWidth: 400 }}
/>,
'파일 업로드는 지원하지 않습니다. 로고 경로를 텍스트로 설정하세요.',
)}
{field(
'회원가입 알림',
<Space direction="vertical" style={{ width: '100%' }}>
<Input
value={config.signup_sms_destno || ''}
onChange={(e) => set('signup_sms_destno', e.target.value)}
placeholder="전화번호 (콤마 구분)"
style={{ maxWidth: 360 }}
/>
<Input
value={config.signup_sms_message || ''}
onChange={(e) => set('signup_sms_message', e.target.value)}
placeholder="발송 메시지 (@영업점 치환)"
style={{ maxWidth: 480 }}
/>
</Space>,
)}
{field(
'접수등록 알림',
<Space direction="vertical" style={{ width: '100%' }}>
<Input
value={config.order_created_sms_destno || ''}
onChange={(e) => set('order_created_sms_destno', e.target.value)}
placeholder="전화번호 (콤마 구분)"
style={{ maxWidth: 360 }}
/>
<Input
value={config.order_created_sms_message || ''}
onChange={(e) => set('order_created_sms_message', e.target.value)}
placeholder="발송 메시지 (@영업점 치환)"
style={{ maxWidth: 480 }}
/>
</Space>,
)}
{field(
'승인 알림',
<Space direction="vertical" style={{ width: '100%' }}>
<Checkbox
checked={chk(config.branch_granted_sms)}
onChange={(e) => setChk('branch_granted_sms', e.target.checked)}
>
승인 메시지를 발송합니다.
</Checkbox>
<Input
value={config.branch_granted_message || ''}
onChange={(e) => set('branch_granted_message', e.target.value)}
style={{ maxWidth: 480 }}
/>
</Space>,
)}
{field(
'접속 허용 IP',
<Input.TextArea
rows={3}
value={config.ip_list || ''}
onChange={(e) => set('ip_list', e.target.value)}
placeholder="공백 또는 콤마로 구분"
/>,
'관리자 접속 시에만 적용됩니다. 비우면 제한 없음.',
)}
{field(
'IP 정책 무시 아이디',
<Input.TextArea
rows={2}
value={config.enable_ip_userid || ''}
onChange={(e) => set('enable_ip_userid', e.target.value)}
placeholder="공백 또는 콤마로 구분"
/>,
)}
{field(
'회원가입 약관',
<Input.TextArea
rows={6}
value={config.agreement || ''}
onChange={(e) => set('agreement', e.target.value)}
/>,
)}
{field(
'로그인 소스 변경',
<Space direction="vertical" style={{ width: '100%' }}>
<Checkbox
checked={chk(config.login_ui_apply)}
onChange={(e) => setChk('login_ui_apply', e.target.checked)}
>
로그인 소스를 적용합니다.
</Checkbox>
<Input.TextArea
rows={6}
value={config.login_ui || ''}
onChange={(e) => set('login_ui', e.target.value)}
/>
</Space>,
)}
</Form>
</Card>
<Card size="small" title="SMS 게이트웨이 (nBill)" style={{ maxWidth: 900, marginBottom: 16 }}>
<Form layout="vertical">
{field(
'회사키(company_key)',
<Input
value={config.company_key || ''}
onChange={(e) => set('company_key', e.target.value)}
/>,
)}
{field(
'관제 서버 URL(manager_url)',
<Input
value={config.manager_url || ''}
onChange={(e) => set('manager_url', e.target.value)}
/>,
)}
<Button type="primary" loading={saveMut.isPending} onClick={() => saveMut.mutate()}>
저장
</Button>
</Form>
</Card>
<Card size="small" title="기능 토글" style={{ maxWidth: 900 }}>
{TOGGLES.map(([key, label]) => (
<div
key={key}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 0',
borderBottom: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
}}
>
<span>{label}</span>
<Button
size="small"
type={config[key] === 'true' ? 'primary' : 'default'}
className="branch-row-btn"
loading={toggleMut.isPending}
onClick={() => toggleMut.mutate(key)}
>
{config[key] === 'true' ? 'ON' : 'OFF'}
</Button>
</div>
))}
</Card>
</div>
)
}
+590
View File
@@ -0,0 +1,590 @@
import { useEffect, useMemo, useState } from 'react'
import { useSearchParams } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Button,
Card,
DatePicker,
Form,
Input,
InputNumber,
Select,
Space,
Tabs,
Typography,
message,
} from 'antd'
import { SettingOutlined } from '@ant-design/icons'
import dayjs from 'dayjs'
import CareTable from '../../components/CareTable'
import DraggableModal from '../../components/DraggableModal'
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
type TabKey = 'center' | 'group' | 'branch'
type Target = { id: number; name: string }
type Isp = { id: number; name?: string; telecom?: string }
type FeeSheet = {
id: number
type?: string
ispId?: number
ispName?: string
carrier?: string
memo?: string
startDate?: string
category?: string
}
type FeeProduct = { id?: number; productType: string; productName: string; fee: number }
type FeeDetail = {
fee: {
id: number
type?: string
carrier?: string
ispName?: string
startDate?: string
memo?: string
}
products: FeeProduct[]
}
const PRODUCT_TYPE: Record<string, string> = {
e01: '인터넷',
e02: '전화',
e03: 'TV',
e04: '렌탈',
}
async function loadTargets(tab: TabKey): Promise<Target[]> {
try {
const { data } = await client.get<ApiResponse<Target[]>>(
`${apiPrefix()}/homes/fee-targets/${tab}`,
)
const rows = unwrap(data)
if (rows?.length) return rows
} catch {
/* fallback below */
}
if (tab === 'center') {
const { data } = await client.get<ApiResponse<{ id: number; name?: string }[]>>(
`${apiPrefix()}/homes/input-centers`,
)
return (unwrap(data) || []).map((r) => ({ id: r.id, name: r.name || `#${r.id}` }))
}
if (tab === 'group') {
const { data } = await client.get<ApiResponse<{ id: number; name?: string }[]>>(
`${apiPrefix()}/homes/branch-groups`,
)
return (unwrap(data) || []).map((r) => ({ id: r.id, name: r.name || `#${r.id}` }))
}
const { data } = await client.get<ApiResponse<{ id: number; name?: string; officeName?: string }[]>>(
`${apiPrefix()}/homes/branches`,
)
return (unwrap(data) || []).map((r) => ({
id: r.id,
name: r.officeName || r.name || `#${r.id}`,
}))
}
function listUrl(tab: TabKey, tid: number) {
if (tab === 'center') return `${apiPrefix()}/homes/center-fees/center/${tid}`
if (tab === 'group') return `${apiPrefix()}/homes/branch-groups/${tid}/fees`
return `${apiPrefix()}/homes/branches/${tid}/fees`
}
function detailBase(tab: TabKey) {
if (tab === 'center') return `${apiPrefix()}/homes/center-fees`
if (tab === 'group') return `${apiPrefix()}/homes/branch-group-fees`
return `${apiPrefix()}/homes/branch-fees`
}
export default function SettingFeePage() {
const [sp] = useSearchParams()
const initTab = (sp.get('tab') as TabKey) || 'center'
const initTarget = sp.get('targetId') ? Number(sp.get('targetId')) : null
const [tab, setTab] = useState<TabKey>(
['center', 'group', 'branch'].includes(initTab) ? initTab : 'center',
)
return (
<div className="stock-page">
<div className="stock-title">
<div>
<h2>
<SettingOutlined className="product-title-icon" />
수수료관리
</h2>
<p>입력처·그룹·영업점 수수료 시트를 관리합니다.</p>
</div>
</div>
<Card size="small">
<Tabs
activeKey={tab}
onChange={(k) => setTab(k as TabKey)}
items={[
{
key: 'center',
label: '센터 수수료',
children: (
<FeeSection
tab="center"
initialTargetId={initTab === 'center' ? initTarget : null}
/>
),
},
{
key: 'group',
label: '그룹 수수료',
children: (
<FeeSection
tab="group"
initialTargetId={initTab === 'group' ? initTarget : null}
/>
),
},
{
key: 'branch',
label: '개별(영업점) 수수료',
children: (
<FeeSection
tab="branch"
initialTargetId={initTab === 'branch' ? initTarget : null}
/>
),
},
]}
/>
</Card>
</div>
)
}
function FeeSection({
tab,
initialTargetId = null,
}: {
tab: TabKey
initialTargetId?: number | null
}) {
const qc = useQueryClient()
const [targetId, setTargetId] = useState<number | null>(initialTargetId)
const [createOpen, setCreateOpen] = useState(false)
const [detailOpen, setDetailOpen] = useState(false)
const [detailFeeId, setDetailFeeId] = useState<number | null>(null)
const [detailType, setDetailType] = useState<'alone' | 'group'>('alone')
const [editable, setEditable] = useState(false)
const [memo, setMemo] = useState('')
const [startDate, setStartDate] = useState('')
const [products, setProducts] = useState<FeeProduct[]>([])
const [createForm] = Form.useForm()
const targetsQuery = useQuery({
queryKey: ['homes-fee-targets', tab],
queryFn: () => loadTargets(tab),
})
const sheetsQuery = useQuery({
queryKey: ['homes-fee-sheets', tab, targetId],
enabled: targetId != null,
queryFn: async () => {
const { data } = await client.get<ApiResponse<FeeSheet[]>>(listUrl(tab, targetId!))
return unwrap(data) || []
},
})
const ispQuery = useQuery({
queryKey: ['homes-isp'],
queryFn: async () => {
const { data } = await client.get<ApiResponse<Isp[]>>(`${apiPrefix()}/homes/isp`)
return unwrap(data) || []
},
})
const detailQuery = useQuery({
queryKey: ['homes-fee-detail', tab, detailFeeId, detailType],
enabled: !!detailFeeId && detailOpen,
queryFn: async () => {
const base =
tab === 'group' || (tab === 'branch' && detailType === 'group')
? `${apiPrefix()}/homes/branch-group-fees`
: detailBase(tab)
const params = tab === 'branch' && detailType === 'alone' ? { type: 'alone' } : undefined
const { data } = await client.get<ApiResponse<FeeDetail>>(
`${base}/${detailFeeId}`,
{ params },
)
return unwrap(data)
},
})
useEffect(() => {
if (!detailQuery.data) return
setMemo(detailQuery.data.fee.memo || '')
setStartDate(detailQuery.data.fee.startDate || '')
setProducts((detailQuery.data.products || []).map((p) => ({ ...p, fee: p.fee ?? 0 })))
if (tab === 'branch') setEditable(detailQuery.data.fee.type === 'alone' || detailType === 'alone')
else setEditable(true)
}, [detailQuery.data, tab, detailType])
const ispLabel = (row: FeeSheet) => {
if (row.ispName || row.carrier) return row.ispName || row.carrier
if (row.ispId) return ispQuery.data?.find((i) => i.id === row.ispId)?.name || row.ispId
return '-'
}
const removeMut = useMutation({
mutationFn: async (row: FeeSheet) => {
const isGroup = tab === 'group' || row.type === 'group'
const base = isGroup && tab !== 'center'
? `${apiPrefix()}/homes/branch-group-fees`
: detailBase(tab)
const { data } = await client.delete(`${base}/${row.id}`)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success('삭제했습니다.')
qc.invalidateQueries({ queryKey: ['homes-fee-sheets', tab, targetId] })
},
onError: (e: Error) => message.error(e.message),
})
const saveMut = useMutation({
mutationFn: async () => {
const body = {
startDate,
memo,
carrier: detailQuery.data?.fee.carrier || detailQuery.data?.fee.ispName,
products: products.map((p) => ({
productType: p.productType,
productName: p.productName,
fee: p.fee ?? 0,
})),
}
const base =
tab === 'group' || detailType === 'group'
? tab === 'center'
? detailBase(tab)
: `${apiPrefix()}/homes/branch-group-fees`
: detailBase(tab)
const { data } = await client.put(`${base}/${detailFeeId}`, body, {
params: tab === 'branch' && detailType === 'alone' ? { type: 'alone' } : undefined,
})
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success('수정되었습니다.')
qc.invalidateQueries({ queryKey: ['homes-fee-detail', tab, detailFeeId, detailType] })
qc.invalidateQueries({ queryKey: ['homes-fee-sheets', tab, targetId] })
},
onError: (e: Error) => message.error(e.message),
})
const createMut = useMutation({
mutationFn: async (values: Record<string, unknown>) => {
const isp = ispQuery.data?.find((i) => i.id === values.ispId)
const carrier = isp?.name || isp?.telecom || String(values.carrier || '')
const sd = values.startDate
const startDateStr =
sd && typeof sd === 'object' && 'format' in (sd as object)
? (sd as { format: (f: string) => string }).format('YYYY-MM-DD HH:mm:ss')
: sd
? dayjs(String(sd)).format('YYYY-MM-DD HH:mm:ss')
: dayjs().format('YYYY-MM-DD 00:00:00')
const productRows = (values.products as FeeProduct[]) || []
const body = {
ispId: values.ispId,
carrier,
startDate: startDateStr,
memo: values.memo || '',
products: productRows.map((p) => ({
productType: p.productType,
productName: p.productName,
fee: Number(p.fee) || 0,
})),
}
const { data } = await client.post(listUrl(tab, targetId!), body)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success('등록되었습니다.')
setCreateOpen(false)
createForm.resetFields()
qc.invalidateQueries({ queryKey: ['homes-fee-sheets', tab, targetId] })
},
onError: (e: Error) => message.error(e.message),
})
const openDetail = (row: FeeSheet, edit: boolean) => {
const isGroup = row.type === 'group' || tab === 'group'
setDetailType(isGroup ? 'group' : 'alone')
setDetailFeeId(row.id)
setDetailOpen(true)
// 그룹 탭에서는 그룹 수수료 수정 가능. 개별 탭의 그룹 시트는 읽기 전용.
setEditable(edit && (tab !== 'branch' || row.type !== 'group'))
}
const sheets = useMemo(() => sheetsQuery.data || [], [sheetsQuery.data])
return (
<div>
<Space wrap style={{ marginBottom: 12 }}>
<Select
style={{ minWidth: 220 }}
placeholder={`${tab === 'center' ? '센터' : tab === 'group' ? '그룹' : '영업점'} 선택`}
value={targetId ?? undefined}
onChange={(v) => {
setTargetId(v)
setCreateOpen(false)
setDetailOpen(false)
}}
options={(targetsQuery.data || []).map((t) => ({ value: t.id, label: t.name }))}
allowClear
/>
{targetId != null && (
<Button
type="primary"
onClick={() => {
createForm.resetFields()
createForm.setFieldsValue({
startDate: dayjs().startOf('day'),
memo: '',
products: [{ productType: 'e01', productName: '', fee: 0 }],
})
setCreateOpen(true)
}}
>
새 수수료 등록
</Button>
)}
</Space>
{targetId == null ? (
<Typography.Text type="secondary">대상을 선택하세요.</Typography.Text>
) : (
<CareTable
rowKey={(r) => `${r.type || 'alone'}-${r.id}`}
size="small"
loading={sheetsQuery.isLoading}
dataSource={sheets}
pagination={{ pageSize: 20, showSizeChanger: false }}
locale={{ emptyText: '등록된 수수료가 없습니다.' }}
columns={[
{ title: 'ID', dataIndex: 'id', width: 70, align: 'center' as const },
{
title: '통신사',
width: 120,
render: (_: unknown, row: FeeSheet) => ispLabel(row),
},
{
title: '적용일',
dataIndex: 'startDate',
width: 160,
align: 'center' as const,
render: (v?: string) => (v || '').slice(0, 16),
},
{
title: '분류',
width: 80,
align: 'center' as const,
render: (_: unknown, row: FeeSheet) =>
row.category || (row.type === 'group' ? '그룹' : tab === 'branch' ? '개별' : '-'),
},
{ title: '메모', dataIndex: 'memo', ellipsis: true },
{
title: '',
width: 200,
render: (_: unknown, row: FeeSheet) => (
<Space size={4}>
<Button
size="small"
type="primary"
className="branch-row-btn"
onClick={() => openDetail(row, false)}
>
상세
</Button>
{(row.type !== 'group' || tab !== 'branch') && (
<Button
size="small"
type="primary"
className="branch-row-btn"
onClick={() => openDetail(row, true)}
>
수정
</Button>
)}
{(row.type !== 'group' || tab !== 'branch') && (
<Button
size="small"
danger
onClick={() => {
if (window.confirm('삭제하시겠습니까?')) removeMut.mutate(row)
}}
>
삭제
</Button>
)}
</Space>
),
},
]}
/>
)}
<DraggableModal
title="수수료 등록"
open={createOpen}
onCancel={() => setCreateOpen(false)}
onOk={() => createForm.submit()}
confirmLoading={createMut.isPending}
width={640}
destroyOnClose
okText="등록"
>
<Form form={createForm} layout="vertical" onFinish={(v) => createMut.mutate(v)}>
<Form.Item name="ispId" label="통신사" rules={[{ required: true, message: '통신사를 선택하세요.' }]}>
<Select
options={(ispQuery.data || []).map((i) => ({
value: i.id,
label: i.name || i.telecom || `#${i.id}`,
}))}
/>
</Form.Item>
<Form.Item name="startDate" label="적용일" rules={[{ required: true }]}>
<DatePicker showTime style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="memo" label="메모">
<Input />
</Form.Item>
<Form.List name="products">
{(fields, { add, remove }) => (
<>
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between' }}>
<Typography.Text strong>상품별 수수료</Typography.Text>
<Button size="small" onClick={() => add({ productType: 'e01', productName: '', fee: 0 })}>
행 추가
</Button>
</div>
{fields.map((field) => (
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }} wrap>
<Form.Item {...field} name={[field.name, 'productType']} rules={[{ required: true }]}>
<Select
style={{ width: 100 }}
options={Object.entries(PRODUCT_TYPE).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item {...field} name={[field.name, 'productName']} rules={[{ required: true, message: '상품명' }]}>
<Input placeholder="상품명" style={{ width: 180 }} />
</Form.Item>
<Form.Item {...field} name={[field.name, 'fee']}>
<InputNumber placeholder="수수료" style={{ width: 120 }} />
</Form.Item>
<Button size="small" danger onClick={() => remove(field.name)}>
삭제
</Button>
</Space>
))}
</>
)}
</Form.List>
</Form>
</DraggableModal>
<DraggableModal
title={`수수료 상세${detailType === 'group' ? ' (그룹)' : ''}`}
open={detailOpen}
onCancel={() => {
setDetailOpen(false)
setDetailFeeId(null)
}}
width={680}
destroyOnClose
footer={(
<Space>
<Button
onClick={() => {
setDetailOpen(false)
setDetailFeeId(null)
}}
>
닫기
</Button>
{editable && (
<Button type="primary" loading={saveMut.isPending} onClick={() => saveMut.mutate()}>
저장
</Button>
)}
</Space>
)}
>
{detailQuery.isLoading || !detailQuery.data ? (
<Typography.Text type="secondary">불러오는 중...</Typography.Text>
) : (
<>
<Space direction="vertical" style={{ width: '100%', marginBottom: 12 }}>
<div>
<Typography.Text type="secondary">판매처 · </Typography.Text>
{detailQuery.data.fee.carrier || detailQuery.data.fee.ispName || '-'}
</div>
<div>
<Typography.Text type="secondary">적용일 · </Typography.Text>
{editable ? (
<DatePicker
showTime
value={startDate ? dayjs(startDate) : null}
onChange={(d) => setStartDate(d ? d.format('YYYY-MM-DD HH:mm:ss') : '')}
/>
) : (
detailQuery.data.fee.startDate || '-'
)}
</div>
<div>
<Typography.Text type="secondary">메모 · </Typography.Text>
{editable ? (
<Input value={memo} onChange={(e) => setMemo(e.target.value)} />
) : (
detailQuery.data.fee.memo || '-'
)}
</div>
</Space>
<CareTable
rowKey={(r) => `${r.productType}-${r.productName}`}
size="small"
pagination={false}
dataSource={products}
locale={{ emptyText: '상품별 수수료가 없습니다.' }}
columns={[
{
title: '종류',
dataIndex: 'productType',
width: 80,
render: (v: string) => PRODUCT_TYPE[v] || v,
},
{ title: '상품명', dataIndex: 'productName' },
{
title: '수수료',
dataIndex: 'fee',
width: 140,
render: (v: number, _row: FeeProduct, idx: number) =>
editable ? (
<InputNumber
value={v}
style={{ width: '100%' }}
onChange={(n) => {
setProducts((prev) =>
prev.map((p, i) => (i === idx ? { ...p, fee: Number(n) || 0 } : p)),
)
}}
/>
) : (
`${(v ?? 0).toLocaleString('ko-KR')} 원`
),
},
]}
/>
</>
)}
</DraggableModal>
</div>
)
}
@@ -0,0 +1,181 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Button, Input, Space, message } from 'antd'
import CareTable from '../../components/CareTable'
import DraggableModal from '../../components/DraggableModal'
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
type GroupRow = { id: number; name?: string; seq?: number }
const ROW_BTN = 'branch-row-btn isp-row-btn'
/** 통신사 그룹관리 팝업 */
export default function IspGroupManageModal({
open,
onClose,
}: {
open: boolean
onClose: () => void
}) {
const qc = useQueryClient()
const [name, setName] = useState('')
const [editId, setEditId] = useState<number | null>(null)
const [editName, setEditName] = useState('')
const query = useQuery({
queryKey: ['homes-isp-group'],
enabled: open,
queryFn: async () => {
const { data } = await client.get<ApiResponse<GroupRow[]>>(`${apiPrefix()}/homes/isp-group`)
return unwrap(data) || []
},
})
const refresh = () => qc.invalidateQueries({ queryKey: ['homes-isp-group'] })
const addMut = useMutation({
mutationFn: async () => {
const { data } = await client.post(`${apiPrefix()}/homes/isp-group`, { name })
return unwrap(data as ApiResponse)
},
onSuccess: () => {
setName('')
message.success('추가했습니다.')
refresh()
},
onError: (e: Error) => message.error(e.message),
})
const saveMut = useMutation({
mutationFn: async () => {
const { data } = await client.put(`${apiPrefix()}/homes/isp-group/${editId}`, { name: editName })
return unwrap(data as ApiResponse)
},
onSuccess: () => {
setEditId(null)
message.success('저장했습니다.')
refresh()
},
onError: (e: Error) => message.error(e.message),
})
const removeMut = useMutation({
mutationFn: async (id: number) => {
const { data } = await client.delete(`${apiPrefix()}/homes/isp-group/${id}`)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success('삭제했습니다.')
refresh()
},
onError: (e: Error) => message.error(e.message),
})
const handleClose = () => {
setName('')
setEditId(null)
setEditName('')
onClose()
}
return (
<DraggableModal
title="통신사 그룹관리"
open={open}
onCancel={handleClose}
footer={null}
destroyOnClose
width={560}
centered
>
<Space style={{ marginBottom: 12 }} wrap>
<Input
placeholder="새 그룹명"
value={name}
onChange={(e) => setName(e.target.value)}
onPressEnter={() => { if (name.trim()) addMut.mutate() }}
style={{ width: 260 }}
/>
<Button
type="primary"
className={ROW_BTN}
disabled={!name.trim()}
loading={addMut.isPending}
onClick={() => addMut.mutate()}
>
추가
</Button>
</Space>
<CareTable
rowKey="id"
size="small"
loading={query.isLoading}
dataSource={query.data || []}
pagination={false}
locale={{ emptyText: '등록된 그룹이 없습니다.' }}
columns={[
{
title: '순서',
dataIndex: 'seq',
width: 70,
align: 'center' as const,
render: (v?: number) => v ?? '-',
},
{
title: '그룹명',
dataIndex: 'name',
render: (_: unknown, row: GroupRow) =>
editId === row.id ? (
<Input
value={editName}
onChange={(e) => setEditName(e.target.value)}
onPressEnter={() => saveMut.mutate()}
/>
) : (
row.name
),
},
{
title: '',
width: 140,
render: (_: unknown, row: GroupRow) =>
editId === row.id ? (
<Space>
<Button size="small" type="primary" className={ROW_BTN} onClick={() => saveMut.mutate()}>
저장
</Button>
<Button size="small" onClick={() => setEditId(null)}>
취소
</Button>
</Space>
) : (
<Space>
<Button
size="small"
type="primary"
className={ROW_BTN}
onClick={() => {
setEditId(row.id)
setEditName(row.name || '')
}}
>
수정
</Button>
<Button
size="small"
danger
onClick={() => {
if (window.confirm('삭제하시겠습니까?')) removeMut.mutate(row.id)
}}
>
삭제
</Button>
</Space>
),
},
]}
/>
</DraggableModal>
)
}
@@ -0,0 +1,242 @@
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Button, Card, Input, Select, Space, message } from 'antd'
import { SettingOutlined } from '@ant-design/icons'
import CareTable from '../../components/CareTable'
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
type Isp = { id: number; name?: string }
type IspLink = {
id: number
name?: string
url?: string
ispId: number
seq?: number
isOn?: number | boolean
active?: boolean
}
const isActive = (row: { isOn?: number | boolean; active?: boolean }) =>
row.isOn === 1 || row.isOn === true || row.active === true
const empty = { name: '', url: '', ispId: 0 as number }
export default function SettingIspLinkPage() {
const qc = useQueryClient()
const [form, setForm] = useState<{ id?: number; name: string; url: string; ispId: number }>(empty)
const linksQuery = useQuery({
queryKey: ['homes-isp-link'],
queryFn: async () => {
const { data } = await client.get<ApiResponse<IspLink[]>>(`${apiPrefix()}/homes/isp-link`)
return unwrap(data) || []
},
})
const ispQuery = useQuery({
queryKey: ['homes-isp'],
queryFn: async () => {
const { data } = await client.get<ApiResponse<Isp[]>>(`${apiPrefix()}/homes/isp`)
return unwrap(data) || []
},
})
const ispName = (id: number) => ispQuery.data?.find((i) => i.id === id)?.name || id
const refresh = () => qc.invalidateQueries({ queryKey: ['homes-isp-link'] })
const reset = () => setForm(empty)
const valid = form.name.trim() && form.url.trim() && form.ispId
const saveMut = useMutation({
mutationFn: async () => {
const body = { name: form.name, url: form.url, ispId: form.ispId }
if (form.id) {
const { data } = await client.put(`${apiPrefix()}/homes/isp-link/${form.id}`, body)
return unwrap(data as ApiResponse)
}
const { data } = await client.post(`${apiPrefix()}/homes/isp-link`, body)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success(form.id ? '수정했습니다.' : '추가했습니다.')
reset()
refresh()
},
onError: (e: Error) => message.error(e.message),
})
const removeMut = useMutation({
mutationFn: async (id: number) => {
const { data } = await client.delete(`${apiPrefix()}/homes/isp-link/${id}`)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success('삭제했습니다.')
refresh()
},
onError: (e: Error) => message.error(e.message),
})
const onoffMut = useMutation({
mutationFn: async (id: number) => {
const { data } = await client.post(`${apiPrefix()}/homes/isp-link/${id}/onoff`)
return unwrap(data as ApiResponse)
},
onSuccess: () => refresh(),
onError: (e: Error) => message.error(e.message),
})
const moveMut = useMutation({
mutationFn: async ({ id, dir }: { id: number; dir: 'up' | 'down' }) => {
const { data } = await client.post(`${apiPrefix()}/homes/isp-link/${id}/${dir}`)
return unwrap(data as ApiResponse)
},
onSuccess: () => refresh(),
onError: (e: Error) => message.error(e.message),
})
const columns = useMemo(
() => [
{
title: '순서',
width: 100,
render: (_: unknown, row: IspLink) => (
<Space size={4}>
<span>{row.seq ?? '-'}</span>
<Button size="small" onClick={() => moveMut.mutate({ id: row.id, dir: 'up' })}>
▲
</Button>
<Button size="small" onClick={() => moveMut.mutate({ id: row.id, dir: 'down' })}>
▼
</Button>
</Space>
),
},
{
title: '통신사',
width: 120,
render: (_: unknown, row: IspLink) => ispName(row.ispId),
},
{ title: '인증링크명', dataIndex: 'name', ellipsis: true },
{
title: 'URL',
dataIndex: 'url',
ellipsis: true,
render: (v?: string) =>
v ? (
<a href={v} target="_blank" rel="noreferrer">
{v}
</a>
) : (
''
),
},
{
title: '상태',
width: 80,
align: 'center' as const,
render: (_: unknown, row: IspLink) => (
<Button
size="small"
type={isActive(row) ? 'primary' : 'default'}
className="branch-row-btn"
onClick={() => onoffMut.mutate(row.id)}
>
{isActive(row) ? '사용' : '중지'}
</Button>
),
},
{
title: '',
width: 140,
render: (_: unknown, row: IspLink) => (
<Space>
<Button
size="small"
type="primary"
className="branch-row-btn"
onClick={() =>
setForm({
id: row.id,
name: row.name || '',
url: row.url || '',
ispId: row.ispId,
})
}
>
수정
</Button>
<Button
size="small"
danger
onClick={() => {
if (window.confirm('삭제하시겠습니까?')) removeMut.mutate(row.id)
}}
>
삭제
</Button>
</Space>
),
},
],
[ispQuery.data, moveMut, onoffMut, removeMut],
)
return (
<div className="stock-page">
<div className="stock-title">
<div>
<h2>
<SettingOutlined className="product-title-icon" />
통신사인증링크
</h2>
<p>통신사별 인증 링크를 등록·관리합니다.</p>
</div>
</div>
<Card size="small" style={{ marginBottom: 12 }}>
<Space wrap>
<Select
style={{ width: 180 }}
placeholder="통신사 선택"
value={form.ispId || undefined}
onChange={(v) => setForm({ ...form, ispId: v })}
options={(ispQuery.data || []).map((i) => ({ value: i.id, label: i.name }))}
/>
<Input
placeholder="인증링크명"
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
style={{ width: 180 }}
/>
<Input
placeholder="URL"
value={form.url}
onChange={(e) => setForm({ ...form, url: e.target.value })}
style={{ minWidth: 280, flex: 1 }}
/>
<Button type="primary" disabled={!valid} loading={saveMut.isPending} onClick={() => saveMut.mutate()}>
{form.id ? '수정' : '추가'}
</Button>
{form.id && (
<Button onClick={reset}>
취소
</Button>
)}
</Space>
</Card>
<Card size="small">
<CareTable
rowKey="id"
size="small"
loading={linksQuery.isLoading}
dataSource={linksQuery.data || []}
columns={columns}
pagination={false}
locale={{ emptyText: '등록된 인증링크가 없습니다.' }}
scroll={{ x: 860 }}
/>
</Card>
</div>
)
}
+527
View File
@@ -0,0 +1,527 @@
import { useMemo, useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import {
Button,
Card,
Form,
Input,
Select,
Space,
message,
} from 'antd'
import { PlusSquareOutlined, SettingOutlined, TeamOutlined } from '@ant-design/icons'
import CareTable from '../../components/CareTable'
import DraggableModal from '../../components/DraggableModal'
import IspGroupManageModal from './SettingIspGroupPage'
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
type IspRow = {
id: number
telecom?: string
name?: string
seq?: number
isOn?: number | boolean
active?: boolean
isSktb?: boolean
}
type IspProduct = {
id: number
type?: string
productType?: string
name?: string
isOn?: number | boolean
active?: boolean
}
type IspAgreement = {
id: number
name?: string
isOn?: number | boolean
active?: boolean
}
const TELECOMS = ['SK', 'KT', 'LG']
const PRODUCT_TYPES = [
{ value: 'e01', label: '인터넷' },
{ value: 'e02', label: '전화' },
{ value: 'e03', label: 'TV' },
{ value: 'e04', label: '렌탈' },
]
const ROW_BTN = 'branch-row-btn isp-row-btn'
const isActive = (row: { isOn?: number | boolean; active?: boolean }) =>
row.isOn === 1 || row.isOn === true || row.active === true
const productTypeLabel = (t?: string) =>
PRODUCT_TYPES.find((p) => p.value === t)?.label || t || '-'
export default function SettingIspPage() {
const qc = useQueryClient()
const [form] = Form.useForm()
const [open, setOpen] = useState(false)
const [editing, setEditing] = useState<IspRow | null>(null)
const [productIsp, setProductIsp] = useState<IspRow | null>(null)
const [agreementIsp, setAgreementIsp] = useState<IspRow | null>(null)
const [groupOpen, setGroupOpen] = useState(false)
const [productForm] = Form.useForm()
const [agreementForm] = Form.useForm()
const query = useQuery({
queryKey: ['homes-isp'],
queryFn: async () => {
const { data } = await client.get<ApiResponse<IspRow[]>>(`${apiPrefix()}/homes/isp`)
return unwrap(data) || []
},
})
const refresh = () => qc.invalidateQueries({ queryKey: ['homes-isp'] })
const saveMut = useMutation({
mutationFn: async (values: { telecom: string; name: string }) => {
if (editing?.id) {
const { data } = await client.put(`${apiPrefix()}/homes/isp/${editing.id}`, values)
return unwrap(data as ApiResponse)
}
const { data } = await client.post(`${apiPrefix()}/homes/isp`, values)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success(editing ? '수정했습니다.' : '등록했습니다.')
setOpen(false)
setEditing(null)
form.resetFields()
refresh()
},
onError: (e: Error) => message.error(e.message),
})
const onoffMut = useMutation({
mutationFn: async (id: number) => {
const { data } = await client.post(`${apiPrefix()}/homes/isp/${id}/onoff`)
return unwrap(data as ApiResponse)
},
onSuccess: () => refresh(),
onError: (e: Error) => message.error(e.message),
})
const moveMut = useMutation({
mutationFn: async ({ id, dir }: { id: number; dir: 'up' | 'down' }) => {
const { data } = await client.post(`${apiPrefix()}/homes/isp/${id}/updown/${dir}`)
return unwrap(data as ApiResponse)
},
onSuccess: () => refresh(),
onError: (e: Error) => message.error(e.message),
})
const removeMut = useMutation({
mutationFn: async (id: number) => {
const { data } = await client.delete(`${apiPrefix()}/homes/isp/${id}`)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success('삭제했습니다.')
refresh()
},
onError: (e: Error) => message.error(e.message),
})
const productsQuery = useQuery({
queryKey: ['homes-isp-products', productIsp?.id],
enabled: !!productIsp?.id,
queryFn: async () => {
const { data } = await client.get<ApiResponse<IspProduct[]>>(
`${apiPrefix()}/homes/isp/${productIsp!.id}/products`,
)
return unwrap(data) || []
},
})
const addProductMut = useMutation({
mutationFn: async (values: { type: string; name: string }) => {
const { data } = await client.post(`${apiPrefix()}/homes/isp/${productIsp!.id}/products`, {
type: values.type,
productType: values.type,
name: values.name,
})
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success('상품을 추가했습니다.')
productForm.resetFields()
productForm.setFieldsValue({ type: 'e01' })
qc.invalidateQueries({ queryKey: ['homes-isp-products', productIsp?.id] })
},
onError: (e: Error) => message.error(e.message),
})
const delProductMut = useMutation({
mutationFn: async (id: number) => {
const { data } = await client.delete(`${apiPrefix()}/homes/isp/products/${id}`)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success('삭제했습니다.')
qc.invalidateQueries({ queryKey: ['homes-isp-products', productIsp?.id] })
},
onError: (e: Error) => message.error(e.message),
})
const agreementsQuery = useQuery({
queryKey: ['homes-isp-agreements', agreementIsp?.id],
enabled: !!agreementIsp?.id,
queryFn: async () => {
const { data } = await client.get<ApiResponse<IspAgreement[]>>(
`${apiPrefix()}/homes/isp/${agreementIsp!.id}/agreements`,
)
return unwrap(data) || []
},
})
const addAgreementMut = useMutation({
mutationFn: async (values: { name: string }) => {
const { data } = await client.post(
`${apiPrefix()}/homes/isp/${agreementIsp!.id}/agreements`,
values,
)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success('약정을 추가했습니다.')
agreementForm.resetFields()
qc.invalidateQueries({ queryKey: ['homes-isp-agreements', agreementIsp?.id] })
},
onError: (e: Error) => message.error(e.message),
})
const delAgreementMut = useMutation({
mutationFn: async (id: number) => {
const { data } = await client.delete(`${apiPrefix()}/homes/isp/agreements/${id}`)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success('삭제했습니다.')
qc.invalidateQueries({ queryKey: ['homes-isp-agreements', agreementIsp?.id] })
},
onError: (e: Error) => message.error(e.message),
})
const openCreate = () => {
setEditing(null)
form.setFieldsValue({ telecom: 'SK', name: '' })
setOpen(true)
}
const openEdit = (row: IspRow) => {
setEditing(row)
form.setFieldsValue({ telecom: row.telecom || 'SK', name: row.name })
setOpen(true)
}
const columns = useMemo(
() => [
{ title: '본사', dataIndex: 'telecom', width: 70, align: 'center' as const },
{ title: '통신사', dataIndex: 'name', ellipsis: true },
{
title: '수정',
width: 72,
align: 'center' as const,
render: (_: unknown, row: IspRow) => (
<Button size="small" type="primary" className={ROW_BTN} onClick={() => openEdit(row)}>
수정
</Button>
),
},
{
title: '상품관리',
width: 88,
align: 'center' as const,
render: (_: unknown, row: IspRow) => (
<Button
size="small"
type="primary"
className={ROW_BTN}
onClick={() => {
setProductIsp(row)
productForm.setFieldsValue({ type: 'e01', name: '' })
}}
>
상품관리
</Button>
),
},
{
title: '이벤트관리',
width: 96,
align: 'center' as const,
render: () => (
<Button size="small" disabled className="branch-row-btn">
이벤트
</Button>
),
},
{
title: '약정관리',
width: 88,
align: 'center' as const,
render: (_: unknown, row: IspRow) => (
<Button
size="small"
type="primary"
className={ROW_BTN}
onClick={() => {
setAgreementIsp(row)
agreementForm.resetFields()
}}
>
약정관리
</Button>
),
},
{
title: '지사관리',
width: 80,
align: 'center' as const,
render: () => (
<Button size="small" disabled className="branch-row-btn">
지사
</Button>
),
},
{
title: '출력순서',
width: 90,
align: 'center' as const,
render: (_: unknown, row: IspRow) => (
<Space size={4}>
<Button size="small" onClick={() => moveMut.mutate({ id: row.id, dir: 'up' })}>
▲
</Button>
<Button size="small" onClick={() => moveMut.mutate({ id: row.id, dir: 'down' })}>
▼
</Button>
</Space>
),
},
{
title: 'ON/OFF',
width: 80,
align: 'center' as const,
render: (_: unknown, row: IspRow) => (
<Button
size="small"
type={isActive(row) ? 'primary' : 'default'}
className={ROW_BTN}
onClick={() => onoffMut.mutate(row.id)}
>
{isActive(row) ? 'ON' : 'OFF'}
</Button>
),
},
{
title: '삭제',
width: 72,
align: 'center' as const,
render: (_: unknown, row: IspRow) => (
<Button
size="small"
danger
type="primary"
className="branch-row-btn"
onClick={() => {
if (window.confirm('삭제하시겠습니까?')) removeMut.mutate(row.id)
}}
>
삭제
</Button>
),
},
],
[moveMut, onoffMut, removeMut, productForm, agreementForm],
)
return (
<div className="stock-page">
<div className="stock-title">
<div>
<h2>
<SettingOutlined className="product-title-icon" />
통신사관리
</h2>
<p>통신사·상품·약정을 등록하고 관리합니다.</p>
</div>
<Space>
<Button icon={<TeamOutlined />} onClick={() => setGroupOpen(true)}>
그룹관리
</Button>
<Button type="primary" icon={<PlusSquareOutlined />} onClick={openCreate}>
등록
</Button>
</Space>
</div>
<Card size="small">
<CareTable
rowKey="id"
size="small"
loading={query.isLoading}
dataSource={query.data || []}
columns={columns}
pagination={false}
locale={{ emptyText: '등록된 통신사가 없습니다.' }}
scroll={{ x: 960 }}
/>
</Card>
<IspGroupManageModal open={groupOpen} onClose={() => setGroupOpen(false)} />
<DraggableModal
title={editing ? '통신사 수정' : '통신사 등록'}
open={open}
onCancel={() => { setOpen(false); setEditing(null) }}
onOk={() => form.submit()}
confirmLoading={saveMut.isPending}
okText={editing ? '수정' : '등록'}
destroyOnClose
width={420}
>
<Form form={form} layout="vertical" onFinish={(v) => saveMut.mutate(v)}>
<Form.Item name="telecom" label="본사" rules={[{ required: true, message: '본사를 선택하세요.' }]}>
<Select options={TELECOMS.map((t) => ({ value: t, label: t }))} />
</Form.Item>
<Form.Item name="name" label="통신사명" rules={[{ required: true, message: '통신사명을 입력하세요.' }]}>
<Input />
</Form.Item>
</Form>
</DraggableModal>
<DraggableModal
title={`상품관리 — ${productIsp?.name || ''}`}
open={!!productIsp}
onCancel={() => setProductIsp(null)}
footer={null}
destroyOnClose
width={560}
centered
>
<Form
form={productForm}
layout="inline"
style={{ marginBottom: 12 }}
onFinish={(v) => addProductMut.mutate(v)}
initialValues={{ type: 'e01' }}
>
<Form.Item name="type" rules={[{ required: true }]}>
<Select style={{ width: 110 }} options={PRODUCT_TYPES} />
</Form.Item>
<Form.Item name="name" rules={[{ required: true, message: '상품명' }]}>
<Input placeholder="상품명" style={{ width: 200 }} />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={addProductMut.isPending} className={ROW_BTN}>
추가
</Button>
</Form.Item>
</Form>
<CareTable
rowKey="id"
size="small"
loading={productsQuery.isLoading}
dataSource={productsQuery.data || []}
pagination={false}
locale={{ emptyText: '등록된 상품이 없습니다.' }}
columns={[
{
title: '종류',
width: 80,
render: (_: unknown, r: IspProduct) => productTypeLabel(r.productType || r.type),
},
{ title: '상품명', dataIndex: 'name' },
{
title: '상태',
width: 70,
align: 'center' as const,
render: (_: unknown, r: IspProduct) => (isActive(r) ? '사용' : '중지'),
},
{
title: '',
width: 70,
align: 'center' as const,
render: (_: unknown, r: IspProduct) => (
<Button
size="small"
danger
onClick={() => {
if (window.confirm('삭제하시겠습니까?')) delProductMut.mutate(r.id)
}}
>
삭제
</Button>
),
},
]}
/>
</DraggableModal>
<DraggableModal
title={`약정관리 — ${agreementIsp?.name || ''}`}
open={!!agreementIsp}
onCancel={() => setAgreementIsp(null)}
footer={null}
destroyOnClose
width={480}
centered
>
<Form
form={agreementForm}
layout="inline"
style={{ marginBottom: 12 }}
onFinish={(v) => addAgreementMut.mutate(v)}
>
<Form.Item name="name" rules={[{ required: true, message: '약정명' }]}>
<Input placeholder="약정명" style={{ width: 240 }} />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={addAgreementMut.isPending} className={ROW_BTN}>
추가
</Button>
</Form.Item>
</Form>
<CareTable
rowKey="id"
size="small"
loading={agreementsQuery.isLoading}
dataSource={agreementsQuery.data || []}
pagination={false}
locale={{ emptyText: '등록된 약정이 없습니다.' }}
columns={[
{ title: '약정명', dataIndex: 'name' },
{
title: '상태',
width: 70,
align: 'center' as const,
render: (_: unknown, r: IspAgreement) => (isActive(r) ? '사용' : '중지'),
},
{
title: '',
width: 70,
align: 'center' as const,
render: (_: unknown, r: IspAgreement) => (
<Button
size="small"
danger
onClick={() => {
if (window.confirm('삭제하시겠습니까?')) delAgreementMut.mutate(r.id)
}}
>
삭제
</Button>
),
},
]}
/>
</DraggableModal>
</div>
)
}
+2 -2
View File
@@ -18,7 +18,7 @@ type LevelsData = {
const FALLBACK_ROLES = ['총괄', '팀장', '사원', '영업', '딜러', '개발자']
const FALLBACK_ROWS: Record<string, string[]> = {
menu: ['상품관리', '계약관리', '접수관리', '정산관리', '일정관리', '예약관리', '고객관리', '장부관리', '통계관리', '환경설정'],
menu: ['상품관리', '계약관리', '접수관리', '영업점관리', '정산관리', '일정관리', '예약관리', '고객관리', '장부관리', '통계관리', '환경설정'],
product: ['목록조회', '상품등록', '상품수정', '상품삭제', '정책관리', '엑셀다운로드'],
contract: ['목록조회', '계약등록', '계약수정', '상태변경', '엑셀다운로드'],
reception: ['목록조회', '접수등록', '접수수정', '진행변경', '엑셀다운로드'],
@@ -65,7 +65,7 @@ function buildMenuDefaults(): RoleMatrix {
flags[menu] = (() => {
if (role === '총괄' || role === '팀장' || role === '개발자') return true
if (role === '사원') {
return ['계약관리', '접수관리', '정산관리', '일정관리', '예약관리', '고객관리', '장부관리'].includes(menu)
return ['계약관리', '접수관리', '영업점관리', '정산관리', '일정관리', '예약관리', '고객관리', '장부관리'].includes(menu)
}
if (role === '영업' || role === '딜러') {
return ['계약관리', '접수관리', '정산관리', '일정관리', '예약관리'].includes(menu)
@@ -53,6 +53,7 @@ const MENU_EXPOSURE = [
{ key: 'product', label: '상품관리' },
{ key: 'contract', label: '계약관리' },
{ key: 'reception', label: '접수관리' },
{ key: 'branch', label: '영업점관리' },
{ key: 'invoice', label: '정산관리' },
{ key: 'pending', label: '일정관리' },
{ key: 'booking', label: '예약관리' },
@@ -541,7 +542,7 @@ export default function SettingMemberPage() {
loading={saveMut.isPending}
icon={<EditOutlined />}
>
{editing ? '수정합니다' : '등록'}
{editing ? '수정' : '등록'}
</Button>
<Button
onClick={() => {
@@ -268,7 +268,7 @@ export default function SettingPartner1Page() {
loading={saveMut.isPending}
icon={<EditOutlined />}
>
{editing ? '수정합니다' : '등록'}
{editing ? '수정' : '등록'}
</Button>
<Space>
{editing ? (
@@ -0,0 +1,256 @@
import { useState } from 'react'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { Button, Card, Form, Input, Space, Typography, message } from 'antd'
import { SettingOutlined } from '@ant-design/icons'
import CareTable from '../../components/CareTable'
import DraggableModal from '../../components/DraggableModal'
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
type QdCenter = {
id: number
name?: string
authorized?: boolean
baseUrl?: string | null
}
type QdLog = {
id: number
createdAt?: string
name?: string
status?: string
memo?: string
orderQuickDeliveryClientId?: number
}
type LogPage = {
content?: QdLog[]
last?: boolean
totalElements?: number
}
export default function SettingQuickDeliveryPage() {
return (
<div className="stock-page">
<div className="stock-title">
<div>
<h2>
<SettingOutlined className="product-title-icon" />
퀵전송 연동
</h2>
<p>입력처(상부점) 인증과 진행알림 로그를 관리합니다.</p>
</div>
</div>
<CenterAuthSection />
<LogSection />
</div>
)
}
function CenterAuthSection() {
const qc = useQueryClient()
const [authForm] = Form.useForm()
const [target, setTarget] = useState<(QdCenter & { mode: 'auth' | 'reauth' }) | null>(null)
const query = useQuery({
queryKey: ['homes-qd-centers'],
queryFn: async () => {
const { data } = await client.get<ApiResponse<QdCenter[]>>(
`${apiPrefix()}/homes/quick-delivery/centers`,
)
return unwrap(data) || []
},
})
const refresh = () => qc.invalidateQueries({ queryKey: ['homes-qd-centers'] })
const authMut = useMutation({
mutationFn: async (values: { userid: string; password: string; baseUrl?: string }) => {
const id = target!.id
if (target!.mode === 'reauth') {
const { data } = await client.post(
`${apiPrefix()}/homes/quick-delivery/center/${id}/reauth`,
{ userid: values.userid, password: values.password },
)
return unwrap(data as ApiResponse)
}
const { data } = await client.post(
`${apiPrefix()}/homes/quick-delivery/center/${id}/auth`,
values,
)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success('완료되었습니다.')
setTarget(null)
authForm.resetFields()
refresh()
},
onError: (e: Error) => message.error(e.message),
})
const removeMut = useMutation({
mutationFn: async (id: number) => {
const { data } = await client.delete(`${apiPrefix()}/homes/quick-delivery/center/${id}/auth`)
return unwrap(data as ApiResponse)
},
onSuccess: () => {
message.success('인증을 해제했습니다.')
refresh()
},
onError: (e: Error) => message.error(e.message),
})
return (
<Card size="small" title="입력처(상부점) 인증관리" style={{ marginBottom: 16 }}>
<CareTable
rowKey="id"
size="small"
loading={query.isLoading}
dataSource={query.data || []}
pagination={false}
locale={{ emptyText: '등록된 입력처가 없습니다.' }}
columns={[
{ title: '입력처', dataIndex: 'name' },
{
title: '인증상태',
width: 100,
render: (_: unknown, row: QdCenter) =>
row.authorized ? (
<Typography.Text type="success">인증됨</Typography.Text>
) : (
<Typography.Text type="secondary">미인증</Typography.Text>
),
},
{
title: '상부점 URL',
dataIndex: 'baseUrl',
ellipsis: true,
render: (v?: string | null) => v || '-',
},
{
title: '',
width: 160,
render: (_: unknown, row: QdCenter) => (
<Space>
<Button
size="small"
type="primary"
className="branch-row-btn"
onClick={() => {
setTarget({ ...row, mode: row.authorized ? 'reauth' : 'auth' })
authForm.setFieldsValue({
baseUrl: row.baseUrl || '',
userid: '',
password: '',
})
}}
>
{row.authorized ? '재인증' : '인증'}
</Button>
{row.authorized && (
<Button
size="small"
danger
onClick={() => {
if (window.confirm('인증을 해제하시겠습니까?')) removeMut.mutate(row.id)
}}
>
해제
</Button>
)}
</Space>
),
},
]}
/>
<DraggableModal
title={`${target?.name || ''} — ${target?.mode === 'reauth' ? '재인증' : '인증'}`}
open={!!target}
onCancel={() => setTarget(null)}
onOk={() => authForm.submit()}
confirmLoading={authMut.isPending}
destroyOnClose
width={440}
>
<Form form={authForm} layout="vertical" onFinish={(v) => authMut.mutate(v)}>
{target?.mode === 'auth' && (
<Form.Item
name="baseUrl"
label="상부점 기본 URL"
rules={[{ required: true, message: 'URL을 입력하세요.' }]}
>
<Input placeholder="https://upper.example.com" />
</Form.Item>
)}
<Form.Item name="userid" label="상부점 아이디" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="password" label="상부점 비밀번호" rules={[{ required: true }]}>
<Input.Password />
</Form.Item>
</Form>
</DraggableModal>
</Card>
)
}
function LogSection() {
const [page, setPage] = useState(0)
const query = useQuery({
queryKey: ['homes-qd-logs', page],
queryFn: async () => {
const { data } = await client.get<ApiResponse<LogPage>>(
`${apiPrefix()}/homes/quick-delivery/logs`,
{ params: { page, size: 50 } },
)
return unwrap(data) || { content: [], last: true }
},
})
const logs = query.data?.content || []
return (
<Card size="small" title="진행알림 로그(상부점 → 하위점)">
<CareTable
rowKey="id"
size="small"
loading={query.isLoading}
dataSource={logs}
pagination={false}
locale={{ emptyText: '로그가 없습니다.' }}
columns={[
{
title: '일시',
dataIndex: 'createdAt',
width: 150,
render: (v?: string) => (v || '').replace('T', ' ').substring(0, 16),
},
{ title: '작성자', dataIndex: 'name', width: 100 },
{ title: '상태', dataIndex: 'status', width: 100 },
{ title: '메모', dataIndex: 'memo', ellipsis: true },
{
title: '접수',
dataIndex: 'orderQuickDeliveryClientId',
width: 90,
render: (v?: number) => (v != null ? `#${v}` : '-'),
},
]}
/>
<Space style={{ marginTop: 12 }}>
<Button size="small" disabled={page === 0} onClick={() => setPage((p) => p - 1)}>
이전
</Button>
<Typography.Text type="secondary">페이지 {page + 1}</Typography.Text>
<Button
size="small"
disabled={query.data?.last ?? true}
onClick={() => setPage((p) => p + 1)}
>
다음
</Button>
</Space>
</Card>
)
}
@@ -0,0 +1,93 @@
import { useQuery } from '@tanstack/react-query'
import { Card, Typography } from 'antd'
import { SettingOutlined } from '@ant-design/icons'
import CareTable from '../../components/CareTable'
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
type Report = {
lastLogin?: string | null
lastOrderCreated?: string | null
dailyPageViews?: { date: string; count: number }[]
}
export default function SettingUsageReportPage() {
const query = useQuery({
queryKey: ['homes-access-log-report'],
queryFn: async () => {
const { data } = await client.get<ApiResponse<Report>>(
`${apiPrefix()}/homes/access-log/report`,
)
return unwrap(data)
},
})
const fmt = (n: number) => n.toLocaleString('ko-KR')
const views = query.data?.dailyPageViews || []
return (
<div className="stock-page">
<div className="stock-title">
<div>
<h2>
<SettingOutlined className="product-title-icon" />
이용현황
</h2>
<p>로그인·접수·페이지뷰 이용 현황을 확인합니다.</p>
</div>
</div>
{query.isLoading && <Typography.Text type="secondary">로딩 중...</Typography.Text>}
{query.data && (
<>
<Card size="small" title="종합정보" style={{ maxWidth: 520, marginBottom: 16 }}>
<CareTable
size="small"
pagination={false}
showHeader={false}
rowKey="k"
dataSource={[
{ k: 'login', label: '마지막 로그인 일자', value: query.data.lastLogin || '-' },
{
k: 'order',
label: '마지막 접수 등록일자',
value: query.data.lastOrderCreated || '-',
},
]}
columns={[
{ title: '', dataIndex: 'label', width: 180 },
{ title: '', dataIndex: 'value' },
]}
/>
</Card>
<Card size="small" title="최근 1주일 페이지뷰" style={{ maxWidth: 520 }}>
<CareTable
size="small"
pagination={false}
showHeader={false}
rowKey="date"
dataSource={views}
locale={{ emptyText: '데이터가 없습니다.' }}
columns={[
{ title: '일자', dataIndex: 'date', width: 180 },
{
title: '건수',
dataIndex: 'count',
align: 'right' as const,
render: (v: number) => `${fmt(v)} 건`,
},
]}
/>
</Card>
</>
)}
{query.isError && (
<Typography.Text type="danger">
{(query.error as Error)?.message || '이용현황을 불러오지 못했습니다.'}
</Typography.Text>
)}
</div>
)
}
+22
View File
@@ -66,10 +66,24 @@ const SITEMAP_COLUMNS: SitemapSection[][] = [
items: [
{ label: '접수리스트', to: '/care/reception/list' },
] },
{
letter: 'O',
title: '영업점관리',
items: [
{ label: '영업점목록', to: '/care/branch/list' },
] },
{
letter: 'D',
title: '정산관리',
items: [
{ label: '가정산 리스트', to: '/care/calculate/branch?tab=prepared' },
{ label: '정산대기 리스트', to: '/care/calculate/branch?tab=waiting' },
{ label: '정산완료 리스트', to: '/care/calculate/branch?tab=completed' },
{ label: '송금(환수)완료 리스트', to: '/care/calculate/branch?tab=transferred' },
{ label: '입력처 가정산 리스트', to: '/care/calculate/center?tab=prepared' },
{ label: '입력처 정산대기 리스트', to: '/care/calculate/center?tab=waiting' },
{ label: '입력처 정산완료 리스트', to: '/care/calculate/center?tab=completed' },
{ label: '입금완료 리스트', to: '/care/calculate/center?tab=transferred' },
{ label: '정산서발행', to: '/care/invoice/publish' },
{ label: '나의정산서', to: '/care/invoice/my' },
{ label: '정산서이력', to: '/care/invoice/log' },
@@ -134,6 +148,14 @@ const SITEMAP_COLUMNS: SitemapSection[][] = [
{ label: '권한설정', to: '/care/setting/level' },
{ label: '상부점관리', to: '/care/setting/partner1' },
{ label: '하부점관리', to: '/care/setting/partner2' },
{ label: '통신사관리', to: '/care/setting/isp' },
{ label: '입력처관리', to: '/care/setting/center' },
{ label: '통신사인증링크', to: '/care/setting/isp-link' },
{ label: '게시판관리', to: '/care/setting/board-admin' },
{ label: '수수료관리', to: '/care/setting/fee' },
{ label: '퀵전송 연동', to: '/care/setting/quick-delivery' },
{ label: '기타 환경 설정', to: '/care/setting/configure' },
{ label: '이용현황', to: '/care/setting/usage' },
] },
{
letter: 'L',
+1 -1
View File
@@ -483,7 +483,7 @@ export default function SmsAddressPage() {
</Form.Item>
<div className="sms-send-footer">
<Button type="primary" htmlType="submit" loading={saveGroup.isPending}>
{editingGroup ? '수정합니다' : '등록'}
{editingGroup ? '수정' : '등록'}
</Button>
<Button onClick={() => { setGroupOpen(false); setEditingGroup(null) }}>취소</Button>
</div>
+1 -1
View File
@@ -360,7 +360,7 @@ export default function StaffManagePage() {
</Form.Item>
<div className="sms-send-footer">
<Button type="primary" htmlType="submit" loading={saveMut.isPending} icon={<EditOutlined />}>
{editing ? '수정합니다' : '등록'}
{editing ? '수정' : '등록'}
</Button>
<Button onClick={() => { setOpen(false); setEditing(null) }}>취소</Button>
</div>
@@ -0,0 +1,86 @@
/** billing_react 호환 접수/사은품/입력처 상태 코드·라벨 */
export const PROGRESS_STATUS: Record<string, string> = {
f01: '접수', f02: '재접수', f03: '접수진행중', f04: '접수완료',
f05: '승인대기중', f06: '회신요청', f07: '접수반송', f08: '미개통오더반송',
f09: 'TC실패반송', f10: '접수보류', f11: '접수취소', f12: '개통중',
f13: '기사완료', f14: '개통완료', f15: '개통보류', f16: '개통취소',
f17: '개통불가', f18: '정산보류', f19: '위면해지', f20: '직권해지',
f21: '유보중단환수', f22: '해지', f23: '정산대기', f24: '정산완료',
f25: '송금완료', f26: '환수정산완료', f27: '환수완료',
f28: 'VOC(귀책)차감', f29: 'VOC(귀책)차감완료',
}
export const GIFT_STATUS: Record<string, string> = {
g01: '지급요청', g02: '지급재요청', g03: '반송', g04: '처리중',
g05: '지급보류', g06: '지급취소', g07: '지급완료', g08: '회수요청',
g09: '회수(반환)완료', g10: '등록완료', g11: '삭제완료', g12: '입금증요청',
g13: '입금증완료', g14: '입금증첨부완료', g15: '입금증(센터)전달완료',
}
export const CENTER_STATUS: Record<string, string> = {
h01: '정산대기', h02: '정산완료', h03: '입금완료', h04: '정산보류',
h05: '위면해지', h06: '직권해지', h07: '유보중단환수', h08: '해지',
h09: '해지정산완료', h10: '해지송금완료', h11: 'VOC(귀책)송금', h12: 'VOC(귀책)송금완료',
}
export const GIFT_PLACE: Record<string, string> = {
b02: '자체발송', b01: '본사발송', b03: '본사(센터)', b04: '본사(관리자)',
}
export function progressLabel(code?: string | null) {
if (!code) return ''
return PROGRESS_STATUS[code] || code
}
export function giftLabel(code?: string | null) {
if (!code) return ''
return GIFT_STATUS[code] || code
}
export function centerLabel(code?: string | null) {
if (!code) return ''
return CENTER_STATUS[code] || code
}
export function giftPlaceLabel(code?: string | null) {
if (!code) return ''
if (code === '본사') return '본사발송'
if (code === '영업점') return '자체발송'
return GIFT_PLACE[code] || code
}
export function statusOptions(map: Record<string, string>, allowed?: string[]) {
const entries = Object.entries(map)
const filtered = allowed?.length ? entries.filter(([k]) => allowed.includes(k)) : entries
return filtered.map(([value, label]) => ({ value, label }))
}
/** 진행현황 배지 색상 */
export function progressBadgeClass(status?: string) {
const s = (status || '').trim()
if (s === 'f01' || s === 'f02' || s === 'f03' || s === 'f04') return 'badge badge-ps-1'
if (s === 'f05' || s === 'f06' || s === 'f12' || s === 'f13') return 'badge badge-ps-2'
if (s === 'f07' || s === 'f08' || s === 'f09' || s === 'f10' || s === 'f15') return 'badge badge-ps-3'
if (s === 'f14') return 'badge badge-ps-4'
if (s === 'f23') return 'badge badge-ps-5'
if (s === 'f24' || s === 'f25') return 'badge badge-ps-6'
if (s === 'f11' || s === 'f16' || s === 'f17' || s === 'f22' || s.startsWith('f1') && Number(s.slice(1)) >= 19) return 'badge badge-ps-9'
if (s.startsWith('f19') || s.startsWith('f2')) return 'badge badge-ps-9'
// legacy
if (s.startsWith('1.') || s.includes('접수완료')) return 'badge badge-ps-1'
if (s.startsWith('4.') || s.includes('개통완료')) return 'badge badge-ps-4'
if (s.startsWith('6.') || s === '정산완료') return 'badge badge-ps-6'
if (s.startsWith('9.') || s.includes('취소')) return 'badge badge-ps-9'
return 'badge'
}
export function giftBadgeClass(status?: string) {
const s = (status || '').trim()
if (!s || s === '미지급') return 'badge badge-gs-none'
if (s === 'g01' || s === 'g02' || s === '지급대기' || s === '지급요청') return 'badge badge-gs-wait'
if (s === 'g07' || s === '지급완료') return 'badge badge-gs-done'
if (s === 'g03' || s === '반송') return 'badge badge-gs-return'
if (s === 'g04' || s === 'g05') return 'badge badge-ps-3'
return 'badge'
}
+370
View File
@@ -4252,6 +4252,23 @@ html.theme-light .reception-order-page {
border-color: var(--primary);
}
.order-result { margin-left: auto; font-size: 12px; }
.order-attach-buttons {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.order-result-row {
display: flex;
align-items: center;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid var(--border);
}
.order-result-row .order-result {
margin-left: 0;
}
.order-search-fields {
display: flex;
align-items: center;
@@ -4456,3 +4473,356 @@ table.order-list .floating-cell:hover .floating-layer { display: block; }
.reception-bulk-form .of-field-control textarea {
width: 100%;
}
/* 정산현황 가정산 */
.calculate-page .prepared-detail-subtitle { margin: 0 0 8px; font-size: 15px; }
.calculate-page .calc-prepared-table .ant-table-cell { font-size: 12px; padding: 4px 6px !important; vertical-align: top; }
.calculate-page .calc-row-selected > td {
background: color-mix(in srgb, var(--primary) 14%, var(--surface)) !important;
}
.calculate-page button.linkish,
.calc-fullscreen-drawer-root button.linkish,
.calc-prepared-drawer-root button.linkish {
background: transparent !important;
border: 0 !important;
padding: 0;
margin: 0;
box-shadow: none !important;
cursor: pointer;
color: var(--text) !important;
font: inherit;
line-height: inherit;
}
.calculate-page button.linkish:hover,
.calc-fullscreen-drawer-root button.linkish:hover,
.calc-prepared-drawer-root button.linkish:hover {
text-decoration: underline;
background: transparent !important;
color: var(--primary) !important;
}
.calculate-page .summary-table-wrap .ant-table-row { cursor: pointer; }
.calculate-page .summary-table-wrap .ant-table-row:hover > td {
background: color-mix(in srgb, var(--primary) 8%, var(--surface)) !important;
}
/* 정산 상세: 좌측패널까지 덮는 전체화면 Drawer + 테마 색상 통일 */
.calc-fullscreen-drawer-root.ant-drawer-root .ant-drawer-content-wrapper,
.calc-fullscreen-drawer-root .ant-drawer-content-wrapper,
.calc-prepared-drawer-root.ant-drawer-root .ant-drawer-content-wrapper,
.calc-prepared-drawer-root .ant-drawer-content-wrapper {
width: 100vw !important;
max-width: 100vw !important;
left: 0 !important;
right: 0 !important;
}
.calc-fullscreen-drawer-root .ant-drawer-content,
.calc-prepared-drawer-root .ant-drawer-content,
.calc-fullscreen-drawer-root .ant-drawer-wrapper-body,
.calc-prepared-drawer-root .ant-drawer-wrapper-body {
width: 100%;
background: var(--bg) !important;
color: var(--text) !important;
}
.calc-fullscreen-drawer-root .ant-drawer-header,
.calc-prepared-drawer-root .ant-drawer-header {
background: var(--surface) !important;
color: var(--text) !important;
border-bottom: 1px solid var(--border) !important;
}
.calc-fullscreen-drawer-root .ant-drawer-title,
.calc-prepared-drawer-root .ant-drawer-title,
.calc-fullscreen-drawer-root .ant-drawer-close,
.calc-prepared-drawer-root .ant-drawer-close {
color: var(--text) !important;
}
.calc-fullscreen-drawer-root .ant-drawer-close:hover,
.calc-prepared-drawer-root .ant-drawer-close:hover {
background: var(--surface-2) !important;
color: var(--text) !important;
}
.calc-fullscreen-drawer-root .ant-drawer-body,
.calc-prepared-drawer-root .ant-drawer-body {
background: var(--bg) !important;
color: var(--text) !important;
}
.calc-fullscreen-drawer-root .prepared-detail,
.calc-fullscreen-drawer-root .prg-table,
.calc-fullscreen-drawer-root .prg-table th,
.calc-fullscreen-drawer-root .prg-table td,
.calc-prepared-drawer-root .prepared-detail,
.calc-prepared-drawer-root .prg-table,
.calc-prepared-drawer-root .prg-table th,
.calc-prepared-drawer-root .prg-table td {
color: var(--text);
}
.calc-fullscreen-drawer-root .prg-table th,
.calc-prepared-drawer-root .prg-table th {
background: var(--surface-2) !important;
border-color: var(--border) !important;
}
.calc-fullscreen-drawer-root .prg-table td,
.calc-prepared-drawer-root .prg-table td {
background: var(--surface) !important;
border-color: var(--border) !important;
}
.calc-fullscreen-drawer-root .ant-table,
.calc-fullscreen-drawer-root .ant-table-container,
.calc-fullscreen-drawer-root .ant-table-thead > tr > th,
.calc-fullscreen-drawer-root .ant-table-tbody > tr > td,
.calc-prepared-drawer-root .ant-table,
.calc-prepared-drawer-root .ant-table-container,
.calc-prepared-drawer-root .ant-table-thead > tr > th,
.calc-prepared-drawer-root .ant-table-tbody > tr > td {
background: var(--surface) !important;
color: var(--text) !important;
border-color: var(--border) !important;
}
.calc-fullscreen-drawer-root .ant-table-thead > tr > th,
.calc-prepared-drawer-root .ant-table-thead > tr > th {
background: var(--surface-2) !important;
color: var(--text) !important;
}
.calc-fullscreen-drawer-root .ant-table-thead > tr > th *,
.calc-prepared-drawer-root .ant-table-thead > tr > th * {
background: transparent !important;
color: inherit !important;
}
.calc-fullscreen-drawer-root .ant-table-thead > tr > th .ant-table-column-title,
.calc-prepared-drawer-root .ant-table-thead > tr > th .ant-table-column-title {
background: transparent !important;
color: var(--text) !important;
}
.calc-fullscreen-drawer-root .ant-table-cell-fix-hidden,
.calc-prepared-drawer-root .ant-table-cell-fix-hidden {
background: var(--surface-2) !important;
}
.calc-fullscreen-drawer-root .ant-radio-button-wrapper,
.calc-prepared-drawer-root .ant-radio-button-wrapper {
background: var(--surface) !important;
color: var(--text) !important;
border-color: var(--border) !important;
}
.calc-fullscreen-drawer-root .ant-radio-button-wrapper-checked,
.calc-prepared-drawer-root .ant-radio-button-wrapper-checked {
background: color-mix(in srgb, var(--primary) 22%, var(--surface)) !important;
color: var(--text) !important;
border-color: var(--primary) !important;
}
.calc-fullscreen-drawer-root .ant-btn-default,
.calc-prepared-drawer-root .ant-btn-default {
background: var(--surface) !important;
color: var(--text) !important;
border-color: var(--border) !important;
}
.calc-fullscreen-drawer-root .ant-pagination .ant-pagination-item,
.calc-fullscreen-drawer-root .ant-pagination .ant-pagination-prev .ant-pagination-item-link,
.calc-fullscreen-drawer-root .ant-pagination .ant-pagination-next .ant-pagination-item-link,
.calc-prepared-drawer-root .ant-pagination .ant-pagination-item,
.calc-prepared-drawer-root .ant-pagination .ant-pagination-prev .ant-pagination-item-link,
.calc-prepared-drawer-root .ant-pagination .ant-pagination-next .ant-pagination-item-link {
background: var(--surface) !important;
color: var(--text) !important;
border-color: var(--border) !important;
}
.calc-fullscreen-drawer-root .ant-table-tbody > tr.calc-row-selected > td,
.calc-prepared-drawer-root .ant-table-tbody > tr.calc-row-selected > td {
background: color-mix(in srgb, var(--primary) 14%, var(--surface)) !important;
}
.calc-fullscreen-drawer-root .ant-table-tbody > tr:hover > td,
.calc-prepared-drawer-root .ant-table-tbody > tr:hover > td {
background: color-mix(in srgb, var(--primary) 8%, var(--surface)) !important;
}
.calc-fullscreen-drawer-root .ant-input,
.calc-fullscreen-drawer-root .ant-input-number,
.calc-fullscreen-drawer-root .ant-input-number-input,
.calc-fullscreen-drawer-root .ant-picker,
.calc-fullscreen-drawer-root .ant-select-selector,
.calc-prepared-drawer-root .ant-input,
.calc-prepared-drawer-root .ant-input-number,
.calc-prepared-drawer-root .ant-input-number-input,
.calc-prepared-drawer-root .ant-picker,
.calc-prepared-drawer-root .ant-select-selector {
background: var(--surface) !important;
color: var(--text) !important;
border-color: var(--border) !important;
}
.calc-fullscreen-drawer-root .muted,
.calc-prepared-drawer-root .muted {
color: var(--muted) !important;
}
.calc-fullscreen-drawer-root .ant-drawer-header,
.calc-prepared-drawer-root .ant-drawer-header {
display: flex;
align-items: center;
gap: 12px;
}
.calc-fullscreen-drawer-root .ant-drawer-header-title,
.calc-prepared-drawer-root .ant-drawer-header-title {
flex: 1;
margin: 0;
}
.calc-drawer-titlebar {
display: flex;
align-items: center;
width: 100%;
min-width: 0;
}
.calc-drawer-titlebar-left {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.calc-drawer-title-text {
font-size: 15px;
font-weight: 600;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.calc-fullscreen-drawer-root .calc-drawer-back-btn,
.calc-fullscreen-drawer-root .calc-drawer-close-btn,
.calc-prepared-drawer-root .calc-drawer-back-btn,
.calc-prepared-drawer-root .calc-drawer-close-btn {
background: var(--surface-2) !important;
color: var(--text) !important;
border-color: var(--border) !important;
flex-shrink: 0;
}
.calc-fullscreen-drawer-root .calc-drawer-back-btn:hover,
.calc-fullscreen-drawer-root .calc-drawer-close-btn:hover,
.calc-prepared-drawer-root .calc-drawer-back-btn:hover,
.calc-prepared-drawer-root .calc-drawer-close-btn:hover {
background: color-mix(in srgb, var(--primary) 14%, var(--surface-2)) !important;
border-color: var(--primary) !important;
color: var(--text) !important;
}
/* 수수료 현황 팝업 — Drawer(z=1200) 위 + 그라데이션 타이틀바 */
.calc-fee-status-modal.ant-modal,
.calc-fee-alert-modal.ant-modal,
.app-draggable-modal-root:has(.calc-fee-status-modal),
.app-draggable-modal-root:has(.calc-fee-alert-modal) {
z-index: 2200 !important;
}
.calc-fee-status-modal .app-modal-titlebar,
.calc-fee-alert-modal .app-modal-titlebar {
background:
linear-gradient(
to bottom,
#4a5a78 0%,
#3a4760 28%,
#2f3a4f 58%,
#252e40 100%
) !important;
border-bottom-color: #1a2233 !important;
}
.calc-fee-status-modal .app-modal-title,
.calc-fee-alert-modal .app-modal-title {
color: #ffffff !important;
text-shadow: 0 1px 0 rgba(0, 0, 0, 0.45);
}
.calc-fee-status-modal .app-modal-close,
.calc-fee-alert-modal .app-modal-close {
color: #ffffff !important;
border-color: rgba(255, 255, 255, 0.25);
}
.calc-fee-status-modal .app-modal-close:hover,
.calc-fee-alert-modal .app-modal-close:hover {
background: rgba(255, 255, 255, 0.12) !important;
color: #fff !important;
}
.calc-fee-status-modal .ant-modal-content,
.calc-fee-alert-modal .ant-modal-content {
background: var(--surface) !important;
color: var(--text) !important;
}
.calc-fee-alert-modal .ant-modal-header {
background: var(--surface) !important;
border-bottom-color: var(--border) !important;
}
.calc-fee-alert-modal .ant-modal-title,
.calc-fee-alert-modal .ant-modal-close {
color: var(--text) !important;
}
.calc-fee-status-modal .ant-modal-body,
.calc-fee-alert-modal .ant-modal-body {
background: var(--surface) !important;
color: var(--text) !important;
}
.calc-fee-status-modal .prg-table th {
background: var(--surface-2) !important;
color: var(--text) !important;
border-color: var(--border) !important;
}
.calc-fee-status-modal .prg-table td {
background: var(--surface) !important;
color: var(--text) !important;
border-color: var(--border) !important;
}
.calc-fee-status-modal .ant-table-thead > tr > th {
background: var(--surface-2) !important;
color: var(--text) !important;
text-align: center;
}
.calc-fee-status-modal .ant-table-tbody > tr > td {
background: var(--surface) !important;
color: var(--text) !important;
}
.calc-fee-status-modal .ant-table-tbody > tr.calc-fee-row-hit > td {
background: color-mix(in srgb, var(--primary) 16%, var(--surface)) !important;
}
/* 상세리스트 금액 InputNumber 우측정렬 */
.calc-money-input.ant-input-number,
.calc-prepared-table .ant-input-number {
text-align: right;
}
.calc-money-input .ant-input-number-input,
.calc-prepared-table .ant-input-number-input {
text-align: right !important;
}
.branch-list-page .ant-table-tbody > tr.row-selected > td {
background: color-mix(in srgb, var(--primary) 14%, var(--surface)) !important;
}
.branch-list-page .branch-row-btn.ant-btn-primary,
.branch-row-btn.ant-btn-primary {
font-size: 12px;
height: 26px;
padding: 0 10px;
border-radius: 4px;
font-weight: 600;
}
.branch-list-page .branch-row-btn.ant-btn-primary:not(.ant-btn-dangerous),
.branch-row-btn.ant-btn-primary:not(.ant-btn-dangerous) {
background: var(--primary) !important;
border-color: var(--primary) !important;
color: #000 !important;
}
.branch-list-page .branch-row-btn.ant-btn-primary.ant-btn-dangerous,
.branch-row-btn.ant-btn-primary.ant-btn-dangerous {
background: #cf1322 !important;
border-color: #cf1322 !important;
color: #fff !important;
}
/* 모든 목록(테이블) 파란 primary 버튼 텍스트 = 좌측메뉴 active와 동일 검정 */
.ant-table-tbody .ant-btn-primary:not(.ant-btn-dangerous),
.ant-table-tbody .ant-btn-primary:not(.ant-btn-dangerous):hover,
.ant-table-tbody .ant-btn-primary:not(.ant-btn-dangerous):focus,
.ant-table-tbody .ant-btn-primary:not(.ant-btn-dangerous):active,
.member-expo-btn.ant-btn-primary,
.member-expo-btn.ant-btn-primary:hover,
.member-expo-btn.ant-btn-primary:focus {
color: #000 !important;
}
.ant-table-tbody .ant-btn-primary.ant-btn-dangerous,
.ant-table-tbody .ant-btn-primary.ant-btn-dangerous:hover,
.ant-table-tbody .ant-btn-primary.ant-btn-dangerous:focus {
color: #fff !important;
}