diff --git a/backend/src/main/java/com/bizcare/agent/CareBranchController.java b/backend/src/main/java/com/bizcare/agent/CareBranchController.java new file mode 100644 index 0000000..fb51418 --- /dev/null +++ b/backend/src/main/java/com/bizcare/agent/CareBranchController.java @@ -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 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 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 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 groupNames = groupNameMap(u.getGroupId()); + return ApiResponse.ok(list.stream().map(b -> listMap(b, groupNames)).toList()); + } + + @PostMapping("/branches") + @Transactional + ApiResponse createBranch(@RequestBody Map 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 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> entries = new ArrayList<>(); + List 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 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 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 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 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> uniq = new LinkedHashMap<>(); + for (HomesBranchFeeProduct p : samples) { + String key = p.getProductType() + "|" + p.getProductName(); + if (uniq.containsKey(key)) continue; + Map 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 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 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> out = new ArrayList<>(); + int i = 1; + for (String n : names) { + Map 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 feeDetailMap(HomesBranchFee f) { + List 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 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> productMaps = new ArrayList<>(); + for (HomesBranchFeeProduct p : products) { + Map 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 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 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 groupMap(HomesBranchGroup g) { + Map m = new LinkedHashMap<>(); + m.put("id", g.getId()); + m.put("name", g.getName()); + m.put("seq", g.getSeq()); + return m; + } + + private Map listMap(HomesBranch b, Map groupNames) { + Map 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 detailMap(HomesBranch b) { + Map 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 feeEntryMap(HomesBranchFee f, String type) { + Map 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 parseNotify(String json) { + List 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 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; } + } +} diff --git a/backend/src/main/java/com/bizcare/agent/CareCalculateController.java b/backend/src/main/java/com/bizcare/agent/CareCalculateController.java new file mode 100644 index 0000000..198d0e2 --- /dev/null +++ b/backend/src/main/java/com/bizcare/agent/CareCalculateController.java @@ -0,0 +1,1410 @@ +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.math.BigDecimal; +import java.time.*; +import java.time.format.DateTimeFormatter; +import java.util.*; +import java.util.stream.Collectors; + +/** + * 영업점/입력처 정산현황 — billing_react OrderCalculate / OrderCenterCalculate 이식. + * + * 상태전이: + * 가정산(prepared: f19~f23/f28 + receptionCostId NULL) + * -> tossWaiting -> HomesReceptionCost 생성 + * -> tossCompleted -> CostGroup + f24/f26 + * -> tossTransferred -> transferred + f25/f27/f29 + * 입력처: h01/h05~h08/h11 → h02/h09 → h03/h10/h12 + */ +@RestController +@RequestMapping("/api/agent/homes") +@RequiredArgsConstructor +class CareCalculateController { + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private static final DateTimeFormatter DT = DateTimeFormatter.ISO_LOCAL_DATE_TIME; + + private final EntityManager em; + + // ==================== 영업점: 목록(집계) ==================== + + @GetMapping("/calculate/prepared") + ApiResponse prepared(@AuthenticationPrincipal JwtUser u) { + return ApiResponse.ok(summarizeBranch(preparedReceptions(u))); + } + + @GetMapping("/calculate/waiting") + ApiResponse waiting(@AuthenticationPrincipal JwtUser u) { + return ApiResponse.ok(summarizeBranch(waitingReceptions(u))); + } + + @GetMapping("/calculate/suggest") + ApiResponse suggest(@RequestParam String entity, @AuthenticationPrincipal JwtUser u) { + List list = preparedReceptions(u).stream() + .filter(r -> entityKeyBranch(r).equals(entity)).toList(); + return ApiResponse.ok(list.stream().map(r -> { + Map m = suggestBranch(r); + m.put("orderId", r.getId()); + return m; + }).toList()); + } + + @GetMapping("/calculate/prepared/detail") + ApiResponse preparedDetail(@RequestParam String entity, + @RequestParam(required = false) String orderDateStart, + @RequestParam(required = false) String orderDateEnd, + @RequestParam(required = false, defaultValue = "order_date") String dateType, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "0") int page2, + @RequestParam(required = false, defaultValue = "50") int perPage, + @RequestParam(required = false, defaultValue = "order_date") String sorting, + @RequestParam(required = false, defaultValue = "desc") String orderBy, + @RequestParam(required = false) String customerName, + @RequestParam(required = false) String contractNumber, + @RequestParam(required = false) List customerSido, + @RequestParam(required = false) List productType, + @RequestParam(required = false) List carrier, + @AuthenticationPrincipal JwtUser u) { + DetailFilter f = new DetailFilter(orderDateStart, orderDateEnd, dateType, page, page2, perPage, + sorting, orderBy, customerName, contractNumber, customerSido, productType, carrier); + List all = preparedReceptions(u).stream() + .filter(r -> entityKeyBranch(r).equals(entity)) + .filter(r -> matchesDetailFilter(r, f, true)) + .sorted(detailComparator(f, true)) + .toList(); + List plus = all.stream().filter(r -> "f23".equals(ReceptionStatuses.normalizeProgress(r.getProgressStatus()))).toList(); + List minus = all.stream().filter(r -> !"f23".equals(ReceptionStatuses.normalizeProgress(r.getProgressStatus()))).toList(); + HomesReception sample = all.isEmpty() ? preparedReceptions(u).stream().filter(r -> entityKeyBranch(r).equals(entity)).findFirst().orElse(null) : all.get(0); + Map branch = new LinkedHashMap<>(); + branch.put("branchId", entity); + branch.put("officeName", entity); + branch.put("name", sample == null ? "" : blank(sample.getCounselor()) ? "" : sample.getCounselor()); + branch.put("phone", sample == null ? "" : sample.getBranchPhone()); + branch.put("tel", sample == null ? "" : sample.getTel()); + Map out = new LinkedHashMap<>(); + out.put("branch", branch); + out.put("plus", pagePreparedRows(plus, f.page, f.perPage, true)); + out.put("minus", pagePreparedRows(minus, f.page2, f.perPage, false)); + return ApiResponse.ok(out); + } + + @GetMapping("/calculate/waiting/lines") + ApiResponse waitingLines(@RequestParam String entity, @AuthenticationPrincipal JwtUser u) { + List list = waitingReceptions(u).stream() + .filter(r -> entityKeyBranch(r).equals(entity)).toList(); + List> lines = new ArrayList<>(); + for (HomesReception r : list) { + HomesReceptionCost c = em.find(HomesReceptionCost.class, r.getReceptionCostId()); + if (c != null) lines.add(toCostLine(c, r)); + } + return ApiResponse.ok(lines); + } + + @GetMapping("/calculate/waiting/detail") + ApiResponse waitingDetail(@RequestParam String entity, @AuthenticationPrincipal JwtUser u) { + return waitingLines(entity, u); + } + + @GetMapping("/calculate/completed") + ApiResponse completed(@RequestParam(required = false) String entity, + @AuthenticationPrincipal JwtUser u) { + List groups = em.createQuery( + "select g from HomesReceptionCostGroup g where g.groupId=:g and g.transferredAt is null order by g.id desc", + HomesReceptionCostGroup.class) + .setParameter("g", u.getGroupId()).getResultList(); + if (!blank(entity)) { + groups = groups.stream().filter(g -> entity.equals(entityKeyOfBranchName(g.getBranchName()))).toList(); + } + return ApiResponse.ok(groups.stream().map(g -> toGroupMap(g, u)).toList()); + } + + @GetMapping("/calculate/transferred") + ApiResponse transferred(@AuthenticationPrincipal JwtUser u) { + List groups = em.createQuery( + "select g from HomesReceptionCostGroup g where g.groupId=:g and g.transferredAt is not null order by g.id desc", + HomesReceptionCostGroup.class) + .setParameter("g", u.getGroupId()).getResultList(); + return ApiResponse.ok(groups.stream().map(g -> toGroupMap(g, u)).toList()); + } + + @GetMapping("/calculate/group/{id}/lines") + ApiResponse groupLines(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesReceptionCostGroup g = findBranchGroup(id, u); + if (g == null) return ApiResponse.fail("정산 그룹을 찾을 수 없습니다."); + return ApiResponse.ok(costLinesOfGroup(id, u.getGroupId())); + } + + @GetMapping("/calculate/group/{id}/detail") + ApiResponse groupDetail(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesReceptionCostGroup g = findBranchGroup(id, u); + if (g == null) return ApiResponse.fail("정산 그룹을 찾을 수 없습니다."); + Map m = toGroupMap(g, u); + m.put("lines", costLinesOfGroup(id, u.getGroupId())); + return ApiResponse.ok(m); + } + + // ==================== 영업점: 전이/수정 ==================== + + @PostMapping("/calculate/toss-waiting") + @Transactional + ApiResponse tossWaiting(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + String entity = firstNonBlank(str(body.get("entity")), str(body.get("entityKey")), str(body.get("branch")), str(body.get("branchName"))); + List> lines = castLines(body.get("lines")); + if (lines.isEmpty()) return ApiResponse.fail("정산할 접수를 선택하세요."); + int created = 0; + for (Map line : lines) { + Long rid = toLong(line.get("receptionId") != null ? line.get("receptionId") : line.get("orderId")); + HomesReception r = findOwned(rid, u); + if (r == null) return ApiResponse.fail("접수를 찾을 수 없습니다."); + if (!blank(entity) && !entityKeyBranch(r).equals(entity)) { + return ApiResponse.fail("가정산 리스트가 올바르지 않습니다. (#" + rid + ")"); + } + String st = ReceptionStatuses.normalizeProgress(r.getProgressStatus()); + if (r.getReceptionCostId() != null || !ReceptionStatuses.isPreCalc(st)) { + return ApiResponse.fail("가정산 대상이 아닌 접수가 포함되어 있습니다. (#" + rid + ")"); + } + HomesReceptionCost c = new HomesReceptionCost(); + c.setGroupId(u.getGroupId()); + c.setReceptionId(r.getId()); + c.setType(ReceptionStatuses.orderCostType(st)); + c.setTypeDesc(ReceptionStatuses.COST_TYPE.getOrDefault(c.getType(), "")); + applyCostAmounts(c, line, r); + em.persist(c); + em.flush(); + r.setReceptionCostId(c.getId()); + created++; + } + return ApiResponse.ok(Map.of("count", created)); + } + + @PostMapping("/calculate/toss-completed") + @Transactional + ApiResponse tossCompleted(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + String entity = firstNonBlank(str(body.get("entity")), str(body.get("entityKey")), str(body.get("branch")), str(body.get("branchName"))); + Long mergeGroupId = toLong(body.get("mergeGroupId")); + List waiting = waitingReceptions(u).stream() + .filter(r -> blank(entity) || entityKeyBranch(r).equals(entity)) + .toList(); + if (waiting.isEmpty()) return ApiResponse.fail("정산(또는 해지) 리스트가 없습니다."); + + HomesReceptionCostGroup group; + if (mergeGroupId != null) { + group = findBranchGroup(mergeGroupId, u); + if (group == null || group.getTransferredAt() != null) { + return ApiResponse.fail("결합할 정산완료 그룹을 찾을 수 없습니다."); + } + } else { + group = new HomesReceptionCostGroup(); + group.setGroupId(u.getGroupId()); + group.setBranchName(blank(entity) ? "본지점" : entity); + group.setIsTax(1); + group.setRate(BigDecimal.valueOf(10)); + em.persist(group); + em.flush(); + } + + int plus = nz(body.get("plus"), null); + int minus = nz(body.get("minus"), null); + group.setPlusAmt((group.getPlusAmt() == null ? 0 : group.getPlusAmt()) + plus); + group.setMinusAmt((group.getMinusAmt() == null ? 0 : group.getMinusAmt()) + minus); + LocalDateTime completedAt = parseDateTime(str(body.get("completed"))); + group.setCompletedAt(completedAt != null ? completedAt : LocalDateTime.now(KST)); + if (body.containsKey("memo")) group.setMemo(str(body.get("memo"))); + + int lineTotal = 0; + for (HomesReception r : waiting) { + HomesReceptionCost c = em.find(HomesReceptionCost.class, r.getReceptionCostId()); + if (c == null) continue; + c.setCostGroupId(group.getId()); + lineTotal += c.getTotal() == null ? 0 : c.getTotal(); + String st = ReceptionStatuses.normalizeProgress(r.getProgressStatus()); + if ("f23".equals(st)) r.setProgressStatus("f24"); + else if (ReceptionStatuses.isPreCalc(st)) r.setProgressStatus("f26"); + } + group.setTotal((group.getPlusAmt() == null ? 0 : group.getPlusAmt()) + + (group.getMinusAmt() == null ? 0 : group.getMinusAmt()) + + lineTotalOfGroup(group.getId(), u.getGroupId())); + return ApiResponse.ok(Map.of("groupId", group.getId(), "total", group.getTotal())); + } + + @PostMapping("/calculate/group/{id}/toss-transferred") + @Transactional + ApiResponse tossTransferred(@PathVariable Long id, @RequestBody(required = false) Map body, + @AuthenticationPrincipal JwtUser u) { + HomesReceptionCostGroup group = findBranchGroup(id, u); + if (group == null) return ApiResponse.fail("정산 그룹을 찾을 수 없습니다."); + if (group.getTransferredAt() != null) return ApiResponse.fail("이미 송금완료된 정산입니다."); + applyGroupTransferFields(group, body); + group.setTransferredAt(parseDateTime(body != null ? str(body.get("transferred")) : null)); + if (group.getTransferredAt() == null) group.setTransferredAt(LocalDateTime.now(KST)); + for (HomesReceptionCost c : costsOfGroup(id, u.getGroupId())) { + HomesReception r = em.find(HomesReception.class, c.getReceptionId()); + if (r == null) continue; + String st = ReceptionStatuses.normalizeProgress(r.getProgressStatus()); + if ("f24".equals(st)) r.setProgressStatus("f25"); + else if ("f26".equals(st)) r.setProgressStatus("f27"); + else if ("f28".equals(st) || "i06".equals(c.getType())) r.setProgressStatus("f29"); + } + return ApiResponse.ok(toGroupMap(group, u)); + } + + @PostMapping("/calculate/group/{id}/cancel") + @Transactional + ApiResponse cancelCompleted(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesReceptionCostGroup group = findBranchGroup(id, u); + if (group == null) return ApiResponse.fail("정산 그룹을 찾을 수 없습니다."); + if (group.getTransferredAt() != null) return ApiResponse.fail("송금완료된 정산은 취소할 수 없습니다."); + for (HomesReceptionCost c : costsOfGroup(id, u.getGroupId())) { + c.setCostGroupId(null); + HomesReception r = em.find(HomesReception.class, c.getReceptionId()); + if (r == null) continue; + String st = ReceptionStatuses.normalizeProgress(r.getProgressStatus()); + if ("f24".equals(st)) r.setProgressStatus("f23"); + else if ("f26".equals(st)) { + String type = c.getType(); + if ("i02".equals(type)) r.setProgressStatus("f19"); + else if ("i03".equals(type)) r.setProgressStatus("f20"); + else if ("i04".equals(type)) r.setProgressStatus("f21"); + else if ("i06".equals(type)) r.setProgressStatus("f28"); + else r.setProgressStatus("f22"); + } + } + em.remove(group); + return ApiResponse.ok(Map.of("cancelled", true)); + } + + @PostMapping("/calculate/group/{id}") + @Transactional + ApiResponse updateCompleted(@PathVariable Long id, @RequestBody Map body, + @AuthenticationPrincipal JwtUser u) { + HomesReceptionCostGroup group = findBranchGroup(id, u); + if (group == null) return ApiResponse.fail("정산 그룹을 찾을 수 없습니다."); + if (body.containsKey("plus")) group.setPlusAmt(nz(body.get("plus"), null)); + if (body.containsKey("minus")) group.setMinusAmt(nz(body.get("minus"), null)); + if (body.containsKey("memo")) group.setMemo(str(body.get("memo"))); + if (body.containsKey("completed")) { + LocalDateTime t = parseDateTime(str(body.get("completed"))); + if (t != null) group.setCompletedAt(t); + } + recalculateBranchGroupTotal(group, u.getGroupId()); + return ApiResponse.ok(toGroupMap(group, u)); + } + + @PostMapping("/calculate/group/{id}/transferred") + @Transactional + ApiResponse updateTransferred(@PathVariable Long id, @RequestBody Map body, + @AuthenticationPrincipal JwtUser u) { + HomesReceptionCostGroup group = findBranchGroup(id, u); + if (group == null) return ApiResponse.fail("정산 그룹을 찾을 수 없습니다."); + applyGroupTransferFields(group, body); + if (body.containsKey("plus")) group.setPlusAmt(nz(body.get("plus"), null)); + if (body.containsKey("minus")) group.setMinusAmt(nz(body.get("minus"), null)); + if (body.containsKey("memo")) group.setMemo(str(body.get("memo"))); + if (body.containsKey("completed")) { + LocalDateTime t = parseDateTime(str(body.get("completed"))); + if (t != null) group.setCompletedAt(t); + } + if (body.containsKey("transferred")) { + LocalDateTime t = parseDateTime(str(body.get("transferred"))); + if (t != null) group.setTransferredAt(t); + } + recalculateBranchGroupTotal(group, u.getGroupId()); + return ApiResponse.ok(toGroupMap(group, u)); + } + + @PostMapping("/calculate/cost/{costId}") + @Transactional + ApiResponse updateCost(@PathVariable Long costId, @RequestBody Map body, + @AuthenticationPrincipal JwtUser u) { + HomesReceptionCost c = em.find(HomesReceptionCost.class, costId); + if (c == null || !Objects.equals(c.getGroupId(), u.getGroupId())) { + return ApiResponse.fail("정산 라인을 찾을 수 없습니다."); + } + if (body.containsKey("cost")) c.setCost(nz(body.get("cost"), null)); + if (body.containsKey("event")) c.setEventAmt(nz(body.get("event"), null)); + if (body.containsKey("agreement")) c.setAgreementAmt(nz(body.get("agreement"), null)); + if (body.containsKey("local")) c.setLocalAmt(nz(body.get("local"), null)); + if (body.containsKey("gift")) c.setGiftAmt(nz(body.get("gift"), null)); + if (body.containsKey("voc")) c.setVocAmt(nz(body.get("voc"), null)); + if (body.containsKey("etc")) c.setEtcAmt(nz(body.get("etc"), null)); + if (body.containsKey("memo")) c.setMemo(str(body.get("memo"))); + c.setTotal(sumCost(c)); + return ApiResponse.ok(Map.of("id", c.getId(), "total", c.getTotal())); + } + + @PostMapping("/calculate/to-prepared") + @Transactional + ApiResponse toPrepared(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + List ids = toLongList(body.get("receptionIds") != null ? body.get("receptionIds") : body.get("orderIds")); + int n = 0; + for (Long rid : ids) { + HomesReception r = findOwned(rid, u); + if (r == null || r.getReceptionCostId() == null) continue; + HomesReceptionCost c = em.find(HomesReceptionCost.class, r.getReceptionCostId()); + if (c == null || c.getCostGroupId() != null) continue; + em.remove(c); + r.setReceptionCostId(null); + n++; + } + return ApiResponse.ok(Map.of("count", n)); + } + + @PostMapping("/calculate/prepared/cancel") + @Transactional + ApiResponse cancelPrepared(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + List ids = toLongList(body.get("receptionIds") != null ? body.get("receptionIds") : body.get("orderIds")); + if (ids.isEmpty()) return ApiResponse.fail("취소할 가정산 리스트를 선택하세요."); + int n = 0; + for (Long rid : ids) { + HomesReception r = findOwned(rid, u); + if (r == null || r.getReceptionCostId() != null) { + return ApiResponse.fail("취소할 가정산 리스트가 올바르지 않습니다."); + } + String st = ReceptionStatuses.normalizeProgress(r.getProgressStatus()); + if (!ReceptionStatuses.isPreCalc(st)) { + return ApiResponse.fail("취소할 가정산 리스트가 올바르지 않습니다."); + } + if ("f23".equals(st)) { + r.setProgressStatus("f14"); + } else if (!blank(r.getPrevProgressStatus())) { + r.setProgressStatus(r.getPrevProgressStatus()); + } else { + r.setProgressStatus("f14"); + } + n++; + } + return ApiResponse.ok(Map.of("count", n)); + } + + // ==================== 입력처 ==================== + + @GetMapping("/center-calculate/prepared") + ApiResponse centerPrepared(@AuthenticationPrincipal JwtUser u) { + return ApiResponse.ok(summarizeCenter(centerPreparedReceptions(u))); + } + + @GetMapping("/center-calculate/waiting") + ApiResponse centerWaiting(@AuthenticationPrincipal JwtUser u) { + return ApiResponse.ok(summarizeCenter(centerWaitingReceptions(u))); + } + + @GetMapping("/center-calculate/suggest") + ApiResponse centerSuggest(@RequestParam String entity, @AuthenticationPrincipal JwtUser u) { + List list = centerPreparedReceptions(u).stream() + .filter(r -> entityKeyCenter(r).equals(entity)).toList(); + return ApiResponse.ok(list.stream().map(r -> { + Map m = suggestCenter(r); + m.put("orderCenterId", r.getId()); + return m; + }).toList()); + } + + @GetMapping("/center-calculate/prepared/detail") + ApiResponse centerPreparedDetail(@RequestParam String entity, + @RequestParam(required = false) String orderDateStart, + @RequestParam(required = false) String orderDateEnd, + @RequestParam(required = false, defaultValue = "order_date") String dateType, + @RequestParam(required = false, defaultValue = "0") int page, + @RequestParam(required = false, defaultValue = "0") int page2, + @RequestParam(required = false, defaultValue = "50") int perPage, + @RequestParam(required = false, defaultValue = "order_date") String sorting, + @RequestParam(required = false, defaultValue = "desc") String orderBy, + @RequestParam(required = false) String customerName, + @RequestParam(required = false) String contractNumber, + @RequestParam(required = false) List customerSido, + @RequestParam(required = false) List productType, + @RequestParam(required = false) List carrier, + @AuthenticationPrincipal JwtUser u) { + DetailFilter f = new DetailFilter(orderDateStart, orderDateEnd, dateType, page, page2, perPage, + sorting, orderBy, customerName, contractNumber, customerSido, productType, carrier); + List all = centerPreparedReceptions(u).stream() + .filter(r -> entityKeyCenter(r).equals(entity)) + .filter(r -> matchesDetailFilter(r, f, false)) + .sorted(detailComparator(f, false)) + .toList(); + List plus = all.stream() + .filter(r -> "h01".equals(ReceptionStatuses.normalizeCenter(r.getSettleStatus()))).toList(); + List minus = all.stream() + .filter(r -> !"h01".equals(ReceptionStatuses.normalizeCenter(r.getSettleStatus()))).toList(); + HomesReception sample = all.isEmpty() + ? centerPreparedReceptions(u).stream().filter(r -> entityKeyCenter(r).equals(entity)).findFirst().orElse(null) + : all.get(0); + Map center = new LinkedHashMap<>(); + center.put("centerId", entity); + center.put("name", entity); + center.put("manager", sample == null ? "" : blank(sample.getProcessor()) ? "" : sample.getProcessor()); + center.put("phone", sample == null ? "" : sample.getBranchPhone()); + center.put("tel", sample == null ? "" : sample.getTel()); + Map out = new LinkedHashMap<>(); + out.put("center", center); + out.put("plus", pagePreparedRows(plus, f.page, f.perPage, true)); + out.put("minus", pagePreparedRows(minus, f.page2, f.perPage, false)); + return ApiResponse.ok(out); + } + + @GetMapping("/center-calculate/waiting/lines") + ApiResponse centerWaitingLines(@RequestParam String entity, @AuthenticationPrincipal JwtUser u) { + List list = centerWaitingReceptions(u).stream() + .filter(r -> entityKeyCenter(r).equals(entity)).toList(); + List> lines = new ArrayList<>(); + for (HomesReception r : list) { + HomesReceptionCenterCost c = em.find(HomesReceptionCenterCost.class, r.getReceptionCenterCostId()); + if (c != null) lines.add(toCenterCostLine(c, r)); + } + return ApiResponse.ok(lines); + } + + @GetMapping("/center-calculate/waiting/detail") + ApiResponse centerWaitingDetail(@RequestParam String entity, @AuthenticationPrincipal JwtUser u) { + return centerWaitingLines(entity, u); + } + + @GetMapping("/center-calculate/completed") + ApiResponse centerCompleted(@RequestParam(required = false) String entity, + @AuthenticationPrincipal JwtUser u) { + List groups = em.createQuery( + "select g from HomesReceptionCenterCostGroup g where g.groupId=:g and g.transferredAt is null order by g.id desc", + HomesReceptionCenterCostGroup.class) + .setParameter("g", u.getGroupId()).getResultList(); + if (!blank(entity)) { + groups = groups.stream().filter(g -> entity.equals(entityKeyOfBranchName(g.getCenterName()))).toList(); + } + return ApiResponse.ok(groups.stream().map(g -> toCenterGroupMap(g, u)).toList()); + } + + @GetMapping("/center-calculate/transferred") + ApiResponse centerTransferred(@AuthenticationPrincipal JwtUser u) { + List groups = em.createQuery( + "select g from HomesReceptionCenterCostGroup g where g.groupId=:g and g.transferredAt is not null order by g.id desc", + HomesReceptionCenterCostGroup.class) + .setParameter("g", u.getGroupId()).getResultList(); + return ApiResponse.ok(groups.stream().map(g -> toCenterGroupMap(g, u)).toList()); + } + + @GetMapping("/center-calculate/group/{id}/lines") + ApiResponse centerGroupLines(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + if (findCenterGroup(id, u) == null) return ApiResponse.fail("정산 그룹을 찾을 수 없습니다."); + return ApiResponse.ok(centerCostLinesOfGroup(id, u.getGroupId())); + } + + @GetMapping("/center-calculate/group/{id}/detail") + ApiResponse centerGroupDetail(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesReceptionCenterCostGroup g = findCenterGroup(id, u); + if (g == null) return ApiResponse.fail("정산 그룹을 찾을 수 없습니다."); + Map m = toCenterGroupMap(g, u); + m.put("lines", centerCostLinesOfGroup(id, u.getGroupId())); + return ApiResponse.ok(m); + } + + @PostMapping("/center-calculate/toss-waiting") + @Transactional + ApiResponse centerTossWaiting(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + String entity = firstNonBlank(str(body.get("entity")), str(body.get("entityKey")), str(body.get("centerName"))); + List> lines = castLines(body.get("lines")); + if (lines.isEmpty()) return ApiResponse.fail("정산할 접수를 선택하세요."); + int created = 0; + for (Map line : lines) { + Long rid = toLong(line.get("receptionId") != null ? line.get("receptionId") : line.get("orderCenterId")); + HomesReception r = findOwned(rid, u); + if (r == null) return ApiResponse.fail("접수를 찾을 수 없습니다."); + if (!blank(entity) && !entityKeyCenter(r).equals(entity)) { + return ApiResponse.fail("가정산 리스트가 올바르지 않습니다."); + } + String st = ReceptionStatuses.normalizeCenter(r.getSettleStatus()); + if (r.getReceptionCenterCostId() != null || !ReceptionStatuses.isCenterPreCalc(st)) { + return ApiResponse.fail("입력처 가정산 대상이 아닙니다. (#" + rid + ")"); + } + HomesReceptionCenterCost c = new HomesReceptionCenterCost(); + c.setGroupId(u.getGroupId()); + c.setReceptionId(r.getId()); + c.setType(ReceptionStatuses.centerCostType(st)); + c.setTypeDesc(ReceptionStatuses.CENTER_COST_TYPE.getOrDefault(c.getType(), "")); + applyCenterCostAmounts(c, line, r); + em.persist(c); + em.flush(); + r.setReceptionCenterCostId(c.getId()); + created++; + } + return ApiResponse.ok(Map.of("count", created)); + } + + @PostMapping("/center-calculate/toss-completed") + @Transactional + ApiResponse centerTossCompleted(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + String entity = firstNonBlank(str(body.get("entity")), str(body.get("entityKey")), str(body.get("centerName"))); + Long mergeGroupId = toLong(body.get("mergeGroupId")); + List waiting = centerWaitingReceptions(u).stream() + .filter(r -> blank(entity) || entityKeyCenter(r).equals(entity)) + .toList(); + if (waiting.isEmpty()) return ApiResponse.fail("정산 리스트가 없습니다."); + + HomesReceptionCenterCostGroup group; + if (mergeGroupId != null) { + group = findCenterGroup(mergeGroupId, u); + if (group == null || group.getTransferredAt() != null) { + return ApiResponse.fail("결합할 정산완료 그룹을 찾을 수 없습니다."); + } + } else { + group = new HomesReceptionCenterCostGroup(); + group.setGroupId(u.getGroupId()); + group.setCenterName(blank(entity) ? "입력처" : entity); + group.setIsTax(1); + group.setRate(BigDecimal.valueOf(10)); + em.persist(group); + em.flush(); + } + + int plus = nz(body.get("plus"), null); + int minus = nz(body.get("minus"), null); + group.setPlusAmt((group.getPlusAmt() == null ? 0 : group.getPlusAmt()) + plus); + group.setMinusAmt((group.getMinusAmt() == null ? 0 : group.getMinusAmt()) + minus); + LocalDateTime completedAt = parseDateTime(str(body.get("completed"))); + group.setCompletedAt(completedAt != null ? completedAt : LocalDateTime.now(KST)); + if (body.containsKey("memo")) group.setMemo(str(body.get("memo"))); + + for (HomesReception r : waiting) { + HomesReceptionCenterCost c = em.find(HomesReceptionCenterCost.class, r.getReceptionCenterCostId()); + if (c == null) continue; + c.setCostGroupId(group.getId()); + String st = ReceptionStatuses.normalizeCenter(r.getSettleStatus()); + if ("h01".equals(st)) r.setSettleStatus("h02"); + else if (ReceptionStatuses.isCenterPreCalc(st)) r.setSettleStatus("h09"); + } + group.setTotal((group.getPlusAmt() == null ? 0 : group.getPlusAmt()) + + (group.getMinusAmt() == null ? 0 : group.getMinusAmt()) + + centerLineTotalOfGroup(group.getId(), u.getGroupId())); + return ApiResponse.ok(Map.of("groupId", group.getId(), "total", group.getTotal())); + } + + @PostMapping("/center-calculate/group/{id}/toss-transferred") + @Transactional + ApiResponse centerTossTransferred(@PathVariable Long id, @RequestBody(required = false) Map body, + @AuthenticationPrincipal JwtUser u) { + HomesReceptionCenterCostGroup group = findCenterGroup(id, u); + if (group == null) return ApiResponse.fail("정산 그룹을 찾을 수 없습니다."); + if (group.getTransferredAt() != null) return ApiResponse.fail("이미 입금완료된 정산입니다."); + applyCenterGroupTransferFields(group, body); + group.setTransferredAt(parseDateTime(body != null ? str(body.get("transferred")) : null)); + if (group.getTransferredAt() == null) group.setTransferredAt(LocalDateTime.now(KST)); + for (HomesReceptionCenterCost c : centerCostsOfGroup(id, u.getGroupId())) { + HomesReception r = em.find(HomesReception.class, c.getReceptionId()); + if (r == null) continue; + String st = ReceptionStatuses.normalizeCenter(r.getSettleStatus()); + if ("h02".equals(st)) r.setSettleStatus("h03"); + else if ("h09".equals(st)) r.setSettleStatus("h10"); + else if ("h11".equals(st)) r.setSettleStatus("h12"); + } + return ApiResponse.ok(toCenterGroupMap(group, u)); + } + + @PostMapping("/center-calculate/group/{id}/cancel") + @Transactional + ApiResponse centerCancelCompleted(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesReceptionCenterCostGroup group = findCenterGroup(id, u); + if (group == null) return ApiResponse.fail("정산 그룹을 찾을 수 없습니다."); + if (group.getTransferredAt() != null) return ApiResponse.fail("입금완료된 정산은 취소할 수 없습니다."); + for (HomesReceptionCenterCost c : centerCostsOfGroup(id, u.getGroupId())) { + c.setCostGroupId(null); + HomesReception r = em.find(HomesReception.class, c.getReceptionId()); + if (r == null) continue; + String st = ReceptionStatuses.normalizeCenter(r.getSettleStatus()); + if ("h02".equals(st)) r.setSettleStatus("h01"); + else if ("h09".equals(st)) { + String type = c.getType(); + if ("k02".equals(type)) r.setSettleStatus("h05"); + else if ("k03".equals(type)) r.setSettleStatus("h06"); + else if ("k04".equals(type)) r.setSettleStatus("h07"); + else if ("k06".equals(type)) r.setSettleStatus("h11"); + else r.setSettleStatus("h08"); + } + } + em.remove(group); + return ApiResponse.ok(Map.of("cancelled", true)); + } + + @PostMapping("/center-calculate/group/{id}") + @Transactional + ApiResponse centerUpdateCompleted(@PathVariable Long id, @RequestBody Map body, + @AuthenticationPrincipal JwtUser u) { + HomesReceptionCenterCostGroup group = findCenterGroup(id, u); + if (group == null) return ApiResponse.fail("정산 그룹을 찾을 수 없습니다."); + if (body.containsKey("plus")) group.setPlusAmt(nz(body.get("plus"), null)); + if (body.containsKey("minus")) group.setMinusAmt(nz(body.get("minus"), null)); + if (body.containsKey("memo")) group.setMemo(str(body.get("memo"))); + if (body.containsKey("completed")) { + LocalDateTime t = parseDateTime(str(body.get("completed"))); + if (t != null) group.setCompletedAt(t); + } + recalculateCenterGroupTotal(group, u.getGroupId()); + return ApiResponse.ok(toCenterGroupMap(group, u)); + } + + @PostMapping("/center-calculate/group/{id}/transferred") + @Transactional + ApiResponse centerUpdateTransferred(@PathVariable Long id, @RequestBody Map body, + @AuthenticationPrincipal JwtUser u) { + HomesReceptionCenterCostGroup group = findCenterGroup(id, u); + if (group == null) return ApiResponse.fail("정산 그룹을 찾을 수 없습니다."); + applyCenterGroupTransferFields(group, body); + if (body.containsKey("plus")) group.setPlusAmt(nz(body.get("plus"), null)); + if (body.containsKey("minus")) group.setMinusAmt(nz(body.get("minus"), null)); + if (body.containsKey("memo")) group.setMemo(str(body.get("memo"))); + if (body.containsKey("completed")) { + LocalDateTime t = parseDateTime(str(body.get("completed"))); + if (t != null) group.setCompletedAt(t); + } + if (body.containsKey("transferred")) { + LocalDateTime t = parseDateTime(str(body.get("transferred"))); + if (t != null) group.setTransferredAt(t); + } + recalculateCenterGroupTotal(group, u.getGroupId()); + return ApiResponse.ok(toCenterGroupMap(group, u)); + } + + @PostMapping("/center-calculate/cost/{costId}") + @Transactional + ApiResponse centerUpdateCost(@PathVariable Long costId, @RequestBody Map body, + @AuthenticationPrincipal JwtUser u) { + HomesReceptionCenterCost c = em.find(HomesReceptionCenterCost.class, costId); + if (c == null || !Objects.equals(c.getGroupId(), u.getGroupId())) { + return ApiResponse.fail("정산 라인을 찾을 수 없습니다."); + } + if (body.containsKey("cost")) c.setCost(nz(body.get("cost"), null)); + if (body.containsKey("event")) c.setEventAmt(nz(body.get("event"), null)); + if (body.containsKey("agreement")) c.setAgreementAmt(nz(body.get("agreement"), null)); + if (body.containsKey("local")) c.setLocalAmt(nz(body.get("local"), null)); + if (body.containsKey("gift")) c.setGiftAmt(nz(body.get("gift"), null)); + if (body.containsKey("voc")) c.setVocAmt(nz(body.get("voc"), null)); + if (body.containsKey("etc")) c.setEtcAmt(nz(body.get("etc"), null)); + if (body.containsKey("memo")) c.setMemo(str(body.get("memo"))); + c.setTotal(sumCenterCost(c)); + return ApiResponse.ok(Map.of("id", c.getId(), "total", c.getTotal())); + } + + @PostMapping("/center-calculate/to-prepared") + @Transactional + ApiResponse centerToPrepared(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + List ids = toLongList(body.get("receptionIds") != null ? body.get("receptionIds") : body.get("orderCenterIds")); + int n = 0; + for (Long rid : ids) { + HomesReception r = findOwned(rid, u); + if (r == null || r.getReceptionCenterCostId() == null) continue; + HomesReceptionCenterCost c = em.find(HomesReceptionCenterCost.class, r.getReceptionCenterCostId()); + if (c == null || c.getCostGroupId() != null) continue; + em.remove(c); + r.setReceptionCenterCostId(null); + n++; + } + return ApiResponse.ok(Map.of("count", n)); + } + + @PostMapping("/center-calculate/prepared/cancel") + @Transactional + ApiResponse centerCancelPrepared(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + List ids = toLongList(body.get("receptionIds") != null ? body.get("receptionIds") : body.get("orderCenterIds")); + if (ids.isEmpty()) return ApiResponse.fail("취소할 가정산 리스트를 선택하세요."); + int n = 0; + for (Long rid : ids) { + HomesReception r = findOwned(rid, u); + if (r == null || r.getReceptionCenterCostId() != null) { + return ApiResponse.fail("취소할 가정산 리스트가 올바르지 않습니다."); + } + String st = ReceptionStatuses.normalizeCenter(r.getSettleStatus()); + if (!ReceptionStatuses.isCenterPreCalc(st)) { + return ApiResponse.fail("취소할 가정산 리스트가 올바르지 않습니다."); + } + // 입력처 가정산 취소 → 정산보류로 제외 + r.setSettleStatus("h04"); + n++; + } + return ApiResponse.ok(Map.of("count", n)); + } + + // ==================== helpers ==================== + + private List loadGroup(JwtUser u) { + return em.createQuery( + "select e from HomesReception e where e.groupId=:g order by e.orderDate desc, e.id desc", + HomesReception.class) + .setParameter("g", u.getGroupId()).getResultList(); + } + + private List preparedReceptions(JwtUser u) { + return loadGroup(u).stream() + .filter(r -> ReceptionStatuses.isPreCalc(ReceptionStatuses.normalizeProgress(r.getProgressStatus()))) + .filter(r -> r.getReceptionCostId() == null) + .toList(); + } + + private List waitingReceptions(JwtUser u) { + return loadGroup(u).stream() + .filter(r -> ReceptionStatuses.isPreCalc(ReceptionStatuses.normalizeProgress(r.getProgressStatus()))) + .filter(r -> r.getReceptionCostId() != null) + .filter(r -> { + HomesReceptionCost c = em.find(HomesReceptionCost.class, r.getReceptionCostId()); + return c != null && c.getCostGroupId() == null; + }) + .toList(); + } + + private List centerPreparedReceptions(JwtUser u) { + return loadGroup(u).stream() + .filter(r -> ReceptionStatuses.isCenterPreCalc(ReceptionStatuses.normalizeCenter(r.getSettleStatus()))) + .filter(r -> r.getReceptionCenterCostId() == null) + .toList(); + } + + private List centerWaitingReceptions(JwtUser u) { + return loadGroup(u).stream() + .filter(r -> ReceptionStatuses.isCenterPreCalc(ReceptionStatuses.normalizeCenter(r.getSettleStatus()))) + .filter(r -> r.getReceptionCenterCostId() != null) + .filter(r -> { + HomesReceptionCenterCost c = em.find(HomesReceptionCenterCost.class, r.getReceptionCenterCostId()); + return c != null && c.getCostGroupId() == null; + }) + .toList(); + } + + private List> summarizeBranch(List orders) { + Map counts = new LinkedHashMap<>(); + Map sample = new LinkedHashMap<>(); + for (HomesReception r : orders) { + String key = entityKeyBranch(r); + long[] c = counts.computeIfAbsent(key, k -> new long[2]); + if ("f23".equals(ReceptionStatuses.normalizeProgress(r.getProgressStatus()))) c[0]++; + else c[1]++; + sample.putIfAbsent(key, r); + } + List> out = new ArrayList<>(); + for (Map.Entry e : counts.entrySet()) { + HomesReception s = sample.get(e.getKey()); + Map m = new LinkedHashMap<>(); + m.put("entityKey", e.getKey()); + m.put("branchId", e.getKey()); + m.put("officeName", blank(s.getBranch()) ? "본지점" : s.getBranch()); + m.put("name", blank(s.getCounselor()) ? "" : s.getCounselor()); + m.put("phone", s.getBranchPhone()); + m.put("tel", s.getTel()); + m.put("sido", s.getRegion()); + m.put("isTax", 1); + m.put("branchGroupName", s.getBranchGroup()); + m.put("plusCnt", e.getValue()[0]); + m.put("minusCnt", e.getValue()[1]); + out.add(m); + } + return out; + } + + private List> summarizeCenter(List orders) { + Map counts = new LinkedHashMap<>(); + Map sample = new LinkedHashMap<>(); + for (HomesReception r : orders) { + String key = entityKeyCenter(r); + long[] c = counts.computeIfAbsent(key, k -> new long[2]); + if ("h01".equals(ReceptionStatuses.normalizeCenter(r.getSettleStatus()))) c[0]++; + else c[1]++; + sample.putIfAbsent(key, r); + } + List> out = new ArrayList<>(); + for (Map.Entry e : counts.entrySet()) { + HomesReception s = sample.get(e.getKey()); + Map m = new LinkedHashMap<>(); + m.put("entityKey", e.getKey()); + m.put("centerId", e.getKey()); + m.put("name", e.getKey()); + m.put("manager", blank(s.getProcessor()) ? "" : s.getProcessor()); + m.put("phone", s.getBranchPhone()); + m.put("tel", s.getTel()); + m.put("plusCnt", e.getValue()[0]); + m.put("minusCnt", e.getValue()[1]); + out.add(m); + } + return out; + } + + private Map suggestBranch(HomesReception r) { + String st = ReceptionStatuses.normalizeProgress(r.getProgressStatus()); + boolean plus = "f23".equals(st); + int sign = plus ? 1 : -1; + int cost = (r.getAmount() == null ? 0 : r.getAmount().intValue()) * sign; + int gift = 0; + if (r.getGiftCashAmount() != null) gift = -r.getGiftCashAmount().intValue() * sign; + else if (r.getGiftGoodsPrice() != null) gift = -r.getGiftGoodsPrice().intValue() * sign; + Map m = new LinkedHashMap<>(); + m.put("receptionId", r.getId()); + m.put("orderId", r.getId()); + m.put("status", st); + m.put("type", ReceptionStatuses.orderCostType(st)); + m.put("contractNumber", r.getContractNumber()); + m.put("customerName", r.getCustomerName()); + m.put("cost", cost); + m.put("event", 0); + m.put("agreement", 0); + m.put("local", 0); + m.put("gift", gift); + m.put("voc", 0); + m.put("etc", 0); + m.put("total", cost + gift); + return m; + } + + private Map suggestCenter(HomesReception r) { + String st = ReceptionStatuses.normalizeCenter(r.getSettleStatus()); + boolean plus = "h01".equals(st); + int sign = plus ? 1 : -1; + int cost = (r.getAmount() == null ? 0 : r.getAmount().intValue()) * sign; + Map m = new LinkedHashMap<>(); + m.put("receptionId", r.getId()); + m.put("orderCenterId", r.getId()); + m.put("status", st); + m.put("type", ReceptionStatuses.centerCostType(st)); + m.put("contractNumber", r.getContractNumber()); + m.put("customerName", r.getCustomerName()); + m.put("cost", cost); + m.put("event", 0); + m.put("agreement", 0); + m.put("local", 0); + m.put("gift", 0); + m.put("voc", 0); + m.put("etc", 0); + m.put("total", cost); + return m; + } + + private void applyCostAmounts(HomesReceptionCost c, Map line, HomesReception r) { + Map sug = suggestBranch(r); + c.setCost(nz(line.get("cost"), BigDecimal.valueOf(((Number) sug.get("cost")).intValue()))); + c.setEventAmt(nz(line.get("event"), null)); + c.setAgreementAmt(nz(line.get("agreement"), null)); + c.setLocalAmt(nz(line.get("local"), null)); + c.setGiftAmt(line.containsKey("gift") ? nz(line.get("gift"), null) : ((Number) sug.get("gift")).intValue()); + c.setVocAmt(nz(line.get("voc"), null)); + c.setEtcAmt(nz(line.get("etc"), null)); + c.setMemo(str(line.get("memo"))); + c.setTotal(sumCost(c)); + } + + private void applyCenterCostAmounts(HomesReceptionCenterCost c, Map line, HomesReception r) { + Map sug = suggestCenter(r); + c.setCost(nz(line.get("cost"), BigDecimal.valueOf(((Number) sug.get("cost")).intValue()))); + c.setEventAmt(nz(line.get("event"), null)); + c.setAgreementAmt(nz(line.get("agreement"), null)); + c.setLocalAmt(nz(line.get("local"), null)); + c.setGiftAmt(nz(line.get("gift"), null)); + c.setVocAmt(nz(line.get("voc"), null)); + c.setEtcAmt(nz(line.get("etc"), null)); + c.setMemo(str(line.get("memo"))); + c.setTotal(sumCenterCost(c)); + } + + private int sumCost(HomesReceptionCost c) { + return n(c.getCost()) + n(c.getEventAmt()) + n(c.getAgreementAmt()) + n(c.getLocalAmt()) + + n(c.getGiftAmt()) + n(c.getVocAmt()) + n(c.getEtcAmt()); + } + + private int sumCenterCost(HomesReceptionCenterCost c) { + return n(c.getCost()) + n(c.getEventAmt()) + n(c.getAgreementAmt()) + n(c.getLocalAmt()) + + n(c.getGiftAmt()) + n(c.getVocAmt()) + n(c.getEtcAmt()); + } + + private Map toCostLine(HomesReceptionCost c, HomesReception r) { + Map m = new LinkedHashMap<>(); + m.put("id", c.getId()); + m.put("receptionId", r.getId()); + m.put("orderId", r.getId()); + m.put("type", c.getType()); + m.put("typeDesc", c.getTypeDesc()); + m.put("cost", n(c.getCost())); + m.put("event", n(c.getEventAmt())); + m.put("agreement", n(c.getAgreementAmt())); + m.put("local", n(c.getLocalAmt())); + m.put("gift", n(c.getGiftAmt())); + m.put("voc", n(c.getVocAmt())); + m.put("etc", n(c.getEtcAmt())); + m.put("total", n(c.getTotal())); + m.put("memo", c.getMemo()); + m.put("status", ReceptionStatuses.normalizeProgress(r.getProgressStatus())); + m.put("contractNumber", r.getContractNumber()); + m.put("customerName", r.getCustomerName()); + return m; + } + + private Map toCenterCostLine(HomesReceptionCenterCost c, HomesReception r) { + Map m = new LinkedHashMap<>(); + m.put("id", c.getId()); + m.put("receptionId", r.getId()); + m.put("orderCenterId", r.getId()); + m.put("type", c.getType()); + m.put("typeDesc", c.getTypeDesc()); + m.put("cost", n(c.getCost())); + m.put("event", n(c.getEventAmt())); + m.put("agreement", n(c.getAgreementAmt())); + m.put("local", n(c.getLocalAmt())); + m.put("gift", n(c.getGiftAmt())); + m.put("voc", n(c.getVocAmt())); + m.put("etc", n(c.getEtcAmt())); + m.put("total", n(c.getTotal())); + m.put("memo", c.getMemo()); + m.put("status", ReceptionStatuses.normalizeCenter(r.getSettleStatus())); + m.put("contractNumber", r.getContractNumber()); + m.put("customerName", r.getCustomerName()); + return m; + } + + private Map toGroupMap(HomesReceptionCostGroup g, JwtUser u) { + long[] cnt = countPlusMinusBranch(g.getId(), u.getGroupId()); + Map m = new LinkedHashMap<>(); + m.put("id", g.getId()); + m.put("entityKey", entityKeyOfBranchName(g.getBranchName())); + m.put("branchName", g.getBranchName()); + m.put("officeName", g.getBranchName()); + m.put("plus", n(g.getPlusAmt())); + m.put("minus", n(g.getMinusAmt())); + m.put("total", n(g.getTotal())); + m.put("rate", g.getRate() == null ? 0 : g.getRate().doubleValue()); + m.put("isTax", g.getIsTax() == null ? 1 : g.getIsTax()); + m.put("taxDate", fmt(g.getTaxDate())); + m.put("completed", fmt(g.getCompletedAt())); + m.put("transferred", fmt(g.getTransferredAt())); + m.put("bank", g.getBank()); + m.put("accountNumber", g.getAccountNumber()); + m.put("accountName", g.getAccountName()); + m.put("memo", g.getMemo()); + m.put("file1", g.getFile1()); + m.put("file2", g.getFile2()); + m.put("file3", g.getFile3()); + m.put("plusCnt", cnt[0]); + m.put("minusCnt", cnt[1]); + return m; + } + + private Map toCenterGroupMap(HomesReceptionCenterCostGroup g, JwtUser u) { + long[] cnt = countPlusMinusCenter(g.getId(), u.getGroupId()); + Map m = new LinkedHashMap<>(); + m.put("id", g.getId()); + m.put("entityKey", entityKeyOfBranchName(g.getCenterName())); + m.put("centerName", g.getCenterName()); + m.put("plus", n(g.getPlusAmt())); + m.put("minus", n(g.getMinusAmt())); + m.put("total", n(g.getTotal())); + m.put("rate", g.getRate() == null ? 0 : g.getRate().doubleValue()); + m.put("isTax", g.getIsTax() == null ? 1 : g.getIsTax()); + m.put("taxDate", fmt(g.getTaxDate())); + m.put("completed", fmt(g.getCompletedAt())); + m.put("transferred", fmt(g.getTransferredAt())); + m.put("bank", g.getBank()); + m.put("accountNumber", g.getAccountNumber()); + m.put("accountName", g.getAccountName()); + m.put("memo", g.getMemo()); + m.put("file1", g.getFile1()); + m.put("file2", g.getFile2()); + m.put("file3", g.getFile3()); + m.put("plusCnt", cnt[0]); + m.put("minusCnt", cnt[1]); + return m; + } + + private long[] countPlusMinusBranch(Long groupId, String gid) { + long plus = 0, minus = 0; + for (HomesReceptionCost c : costsOfGroup(groupId, gid)) { + if ("i01".equals(c.getType())) plus++; + else minus++; + } + return new long[]{plus, minus}; + } + + private long[] countPlusMinusCenter(Long groupId, String gid) { + long plus = 0, minus = 0; + for (HomesReceptionCenterCost c : centerCostsOfGroup(groupId, gid)) { + if ("k01".equals(c.getType())) plus++; + else minus++; + } + return new long[]{plus, minus}; + } + + private List costsOfGroup(Long groupId, String gid) { + return em.createQuery( + "select c from HomesReceptionCost c where c.groupId=:g and c.costGroupId=:gid", + HomesReceptionCost.class) + .setParameter("g", gid).setParameter("gid", groupId).getResultList(); + } + + private List centerCostsOfGroup(Long groupId, String gid) { + return em.createQuery( + "select c from HomesReceptionCenterCost c where c.groupId=:g and c.costGroupId=:gid", + HomesReceptionCenterCost.class) + .setParameter("g", gid).setParameter("gid", groupId).getResultList(); + } + + private List> costLinesOfGroup(Long groupId, String gid) { + List> lines = new ArrayList<>(); + for (HomesReceptionCost c : costsOfGroup(groupId, gid)) { + HomesReception r = em.find(HomesReception.class, c.getReceptionId()); + if (r != null) lines.add(toCostLine(c, r)); + } + return lines; + } + + private List> centerCostLinesOfGroup(Long groupId, String gid) { + List> lines = new ArrayList<>(); + for (HomesReceptionCenterCost c : centerCostsOfGroup(groupId, gid)) { + HomesReception r = em.find(HomesReception.class, c.getReceptionId()); + if (r != null) lines.add(toCenterCostLine(c, r)); + } + return lines; + } + + private int lineTotalOfGroup(Long groupId, String gid) { + return costsOfGroup(groupId, gid).stream().mapToInt(c -> n(c.getTotal())).sum(); + } + + private int centerLineTotalOfGroup(Long groupId, String gid) { + return centerCostsOfGroup(groupId, gid).stream().mapToInt(c -> n(c.getTotal())).sum(); + } + + private void recalculateBranchGroupTotal(HomesReceptionCostGroup group, String gid) { + group.setTotal(n(group.getPlusAmt()) + n(group.getMinusAmt()) + lineTotalOfGroup(group.getId(), gid)); + } + + private void recalculateCenterGroupTotal(HomesReceptionCenterCostGroup group, String gid) { + group.setTotal(n(group.getPlusAmt()) + n(group.getMinusAmt()) + centerLineTotalOfGroup(group.getId(), gid)); + } + + private void applyGroupTransferFields(HomesReceptionCostGroup group, Map body) { + if (body == null) return; + if (body.containsKey("memo")) group.setMemo(str(body.get("memo"))); + if (body.containsKey("bank")) group.setBank(str(body.get("bank"))); + if (body.containsKey("accountNumber")) group.setAccountNumber(str(body.get("accountNumber"))); + if (body.containsKey("accountName")) group.setAccountName(str(body.get("accountName"))); + if (body.containsKey("rate")) { + try { group.setRate(new BigDecimal(String.valueOf(body.get("rate")))); } catch (Exception ignored) {} + } + if (body.containsKey("taxBill") || body.containsKey("isTax")) { + Object v = body.containsKey("taxBill") ? body.get("taxBill") : body.get("isTax"); + boolean tax = v instanceof Boolean b ? b : "1".equals(String.valueOf(v)) || "true".equalsIgnoreCase(String.valueOf(v)); + group.setIsTax(tax ? 1 : 0); + } + if (body.containsKey("taxDate")) { + LocalDateTime t = parseDateTime(str(body.get("taxDate"))); + if (t != null) group.setTaxDate(t); + } + } + + private void applyCenterGroupTransferFields(HomesReceptionCenterCostGroup group, Map body) { + if (body == null) return; + if (body.containsKey("memo")) group.setMemo(str(body.get("memo"))); + if (body.containsKey("bank")) group.setBank(str(body.get("bank"))); + if (body.containsKey("accountNumber")) group.setAccountNumber(str(body.get("accountNumber"))); + if (body.containsKey("accountName")) group.setAccountName(str(body.get("accountName"))); + if (body.containsKey("rate")) { + try { group.setRate(new BigDecimal(String.valueOf(body.get("rate")))); } catch (Exception ignored) {} + } + if (body.containsKey("taxBill") || body.containsKey("isTax")) { + Object v = body.containsKey("taxBill") ? body.get("taxBill") : body.get("isTax"); + boolean tax = v instanceof Boolean b ? b : "1".equals(String.valueOf(v)) || "true".equalsIgnoreCase(String.valueOf(v)); + group.setIsTax(tax ? 1 : 0); + } + if (body.containsKey("taxDate")) { + LocalDateTime t = parseDateTime(str(body.get("taxDate"))); + if (t != null) group.setTaxDate(t); + } + } + + private HomesReceptionCostGroup findBranchGroup(Long id, JwtUser u) { + if (id == null) return null; + HomesReceptionCostGroup g = em.find(HomesReceptionCostGroup.class, id); + if (g == null || !Objects.equals(g.getGroupId(), u.getGroupId())) return null; + return g; + } + + private HomesReceptionCenterCostGroup findCenterGroup(Long id, JwtUser u) { + if (id == null) return null; + HomesReceptionCenterCostGroup g = em.find(HomesReceptionCenterCostGroup.class, id); + if (g == null || !Objects.equals(g.getGroupId(), u.getGroupId())) return null; + return g; + } + + private HomesReception findOwned(Long id, JwtUser u) { + if (id == null) return null; + HomesReception r = em.find(HomesReception.class, id); + if (r == null || !Objects.equals(r.getGroupId(), u.getGroupId())) return null; + return r; + } + + private static String entityKeyBranch(HomesReception r) { + return entityKeyOfBranchName(r.getBranch()); + } + + private static String entityKeyCenter(HomesReception r) { + String p = r.getProcessor(); + if (p != null && !p.isBlank()) return p.trim(); + String b = r.getBranch(); + return (b == null || b.isBlank()) ? "본사" : b.trim(); + } + + private static String entityKeyOfBranchName(String name) { + return (name == null || name.isBlank()) ? "본지점" : name.trim(); + } + + private record DetailFilter( + String orderDateStart, String orderDateEnd, String dateType, + int page, int page2, int perPage, String sorting, String orderBy, + String customerName, String contractNumber, + List customerSido, List productType, List carrier + ) { + DetailFilter { + if (blank(orderDateStart)) orderDateStart = "2010-01-01"; + if (blank(orderDateEnd)) orderDateEnd = LocalDate.now(KST).toString(); + if (perPage < 10) perPage = 50; + if (page < 0) page = 0; + if (page2 < 0) page2 = 0; + if (blank(sorting)) sorting = "order_date"; + if (blank(orderBy)) orderBy = "desc"; + } + } + + private boolean matchesDetailFilter(HomesReception r, DetailFilter f, boolean branch) { + LocalDateTime start = LocalDate.parse(f.orderDateStart).atStartOfDay(); + LocalDateTime end = LocalDate.parse(f.orderDateEnd).atTime(23, 59, 59); + if ("opended".equals(f.dateType) || "opened".equals(f.dateType)) { + if (r.getOpenDate() == null) return false; + LocalDateTime od = r.getOpenDate().atStartOfDay(); + if (od.isBefore(start) || od.isAfter(end)) return false; + } else { + if (r.getOrderDate() == null) return false; + if (r.getOrderDate().isBefore(start) || r.getOrderDate().isAfter(end)) return false; + } + if (!blank(f.customerName) && (r.getCustomerName() == null + || !r.getCustomerName().toLowerCase().contains(f.customerName.toLowerCase()))) return false; + if (!blank(f.contractNumber)) { + String cn = r.getContractNumber() == null ? "" : r.getContractNumber(); + String on = r.getOrderNumber() == null ? "" : r.getOrderNumber(); + if (!cn.contains(f.contractNumber) && !on.contains(f.contractNumber)) return false; + } + if (f.customerSido != null && !f.customerSido.isEmpty()) { + if (r.getRegion() == null || !f.customerSido.contains(r.getRegion())) return false; + } + if (f.productType != null && !f.productType.isEmpty()) { + if (r.getProductType() == null || !f.productType.contains(r.getProductType())) return false; + } + if (f.carrier != null && !f.carrier.isEmpty()) { + String c = blank(r.getIspName()) ? r.getCarrier() : r.getIspName(); + if (c == null || !f.carrier.contains(c)) return false; + } + return true; + } + + private Comparator detailComparator(DetailFilter f, boolean branch) { + Comparator cmp = switch (f.sorting) { + case "opended", "opened" -> Comparator.comparing(HomesReception::getOpenDate, Comparator.nullsLast(Comparator.naturalOrder())); + case "center" -> Comparator.comparing(r -> blank(r.getProcessor()) ? "" : r.getProcessor()); + case "isp" -> Comparator.comparing(r -> blank(r.getIspName()) ? (r.getCarrier() == null ? "" : r.getCarrier()) : r.getIspName()); + case "product_type" -> Comparator.comparing(r -> r.getProductType() == null ? "" : r.getProductType()); + case "product" -> Comparator.comparing(r -> r.getProductName() == null ? "" : r.getProductName()); + case "agreement" -> Comparator.comparing(r -> r.getAgreementName() == null ? "" : r.getAgreementName()); + case "sido" -> Comparator.comparing(r -> r.getRegion() == null ? "" : r.getRegion()); + case "name" -> Comparator.comparing(r -> r.getCustomerName() == null ? "" : r.getCustomerName()); + case "contract_number" -> Comparator.comparing(r -> r.getContractNumber() == null ? "" : r.getContractNumber()); + default -> Comparator.comparing(HomesReception::getOrderDate, Comparator.nullsLast(Comparator.naturalOrder())); + }; + if ("desc".equalsIgnoreCase(f.orderBy)) cmp = cmp.reversed(); + return cmp.thenComparing(HomesReception::getId, Comparator.reverseOrder()); + } + + private Map pagePreparedRows(List orders, int page, int perPage, boolean plus) { + int total = orders.size(); + int totalPages = total == 0 ? 0 : (int) Math.ceil((double) total / perPage); + int from = Math.min(page * perPage, total); + int to = Math.min(from + perPage, total); + List> content = orders.subList(from, to).stream() + .map(r -> toPreparedDetailRow(r, plus)) + .toList(); + Map m = new LinkedHashMap<>(); + m.put("content", content); + m.put("page", page); + m.put("totalPages", totalPages); + m.put("totalElements", total); + return m; + } + + private Map toPreparedDetailRow(HomesReception r, boolean plus) { + Map sug = suggestBranch(r); + // 입력처 가정산이면 center suggest 금액 사용 가능하나, 상세 컬럼은 동일 구조 + String st = ReceptionStatuses.normalizeProgress(r.getProgressStatus()); + boolean isPlus = "f23".equals(st) || (plus && ReceptionStatuses.isCenterPreCalc(ReceptionStatuses.normalizeCenter(r.getSettleStatus())) + && "h01".equals(ReceptionStatuses.normalizeCenter(r.getSettleStatus()))); + if (!ReceptionStatuses.isPreCalc(st) && ReceptionStatuses.isCenterPreCalc(ReceptionStatuses.normalizeCenter(r.getSettleStatus()))) { + sug = suggestCenter(r); + isPlus = "h01".equals(ReceptionStatuses.normalizeCenter(r.getSettleStatus())); + } + int sign = isPlus ? 1 : -1; + int cost = ((Number) sug.get("cost")).intValue(); + int event = ((Number) sug.get("event")).intValue(); + int agreement = ((Number) sug.get("agreement")).intValue(); + int local = ((Number) sug.get("local")).intValue(); + int gift = ((Number) sug.get("gift")).intValue(); + int voc = ((Number) sug.get("voc")).intValue(); + int etcAmt = ((Number) sug.get("etc")).intValue(); + + int prevCost = 0, prevEvent = 0, prevAgreement = 0, prevLocal = 0, prevGift = 0, prevVoc = 0, prevEtc = 0, prevTotal = 0; + boolean negateOnTransfer = false; + String minusCategory = ""; + if (!isPlus) { + minusCategory = minusCategory(r); + HomesReceptionCost prev = findPrevI01Cost(r.getId()); + if (prev != null) { + prevCost = n(prev.getCost()); + prevEvent = n(prev.getEventAmt()); + prevAgreement = n(prev.getAgreementAmt()); + prevLocal = n(prev.getLocalAmt()); + prevGift = n(prev.getGiftAmt()); + prevVoc = n(prev.getVocAmt()); + prevEtc = n(prev.getEtcAmt()); + prevTotal = n(prev.getTotal()); + if (prev.getCostGroupId() != null) { + HomesReceptionCostGroup g = em.find(HomesReceptionCostGroup.class, prev.getCostGroupId()); + negateOnTransfer = g != null && g.getTransferredAt() != null; + } + if (negateOnTransfer) { + cost = -prevCost; + event = -prevEvent; + agreement = -prevAgreement; + gift = 0; + etcAmt = -prevEtc; + } else { + etcAmt = -prevEtc; + // 기본 suggest는 이미 음수 부호 적용됨 + } + } + } + + Map m = new LinkedHashMap<>(); + m.put("orderId", r.getId()); + m.put("receptionId", r.getId()); + m.put("orderCostId", r.getReceptionCostId()); + m.put("status", blank(st) ? ReceptionStatuses.normalizeCenter(r.getSettleStatus()) : st); + m.put("plus", isPlus); + m.put("orderDate", r.getOrderDate() == null ? "" : r.getOrderDate().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))); + m.put("opened", r.getOpenDate() == null ? "" : r.getOpenDate().toString()); + m.put("centerName", blank(r.getProcessor()) ? "본사" : r.getProcessor()); + m.put("ispName", blank(r.getIspName()) ? (r.getCarrier() == null ? "" : r.getCarrier()) : r.getIspName()); + m.put("productType", r.getProductType() == null ? "" : r.getProductType()); + m.put("productName", r.getProductName() == null ? "" : r.getProductName()); + m.put("eventNames", ""); + m.put("agreementName", r.getAgreementName() == null ? "" : r.getAgreementName()); + m.put("sido", r.getRegion() == null ? "" : r.getRegion()); + m.put("customerName", r.getCustomerName() == null ? "" : r.getCustomerName()); + m.put("ssidUseCount", null); + m.put("contractNumber", blank(r.getContractNumber()) ? (r.getOrderNumber() == null ? "" : r.getOrderNumber()) : r.getContractNumber()); + m.put("minusCategory", minusCategory); + m.put("giftType", r.getGiftCategory() == null ? "" : r.getGiftCategory()); + m.put("giftPlace", r.getGiftPlace() == null ? "" : r.getGiftPlace()); + m.put("giftStatus", ReceptionStatuses.normalizeGift(r.getGiftStatus())); + m.put("progressMemo", r.getDetailProgress() == null ? "" : r.getDetailProgress()); + m.put("cost", cost); + m.put("event", event); + m.put("agreement", agreement); + m.put("local", local); + m.put("gift", gift); + m.put("voc", voc); + m.put("etc", etcAmt); + m.put("total", cost + event + agreement + local + gift + voc + etcAmt); + m.put("prevCost", prevCost); + m.put("prevEvent", prevEvent); + m.put("prevAgreement", prevAgreement); + m.put("prevLocal", prevLocal); + m.put("prevGift", prevGift); + m.put("prevVoc", prevVoc); + m.put("prevEtc", prevEtc); + m.put("prevTotal", prevTotal); + m.put("negateOnTransfer", negateOnTransfer); + return m; + } + + private HomesReceptionCost findPrevI01Cost(Long receptionId) { + List list = em.createQuery( + "select c from HomesReceptionCost c where c.groupId='demo' and c.receptionId=:rid and c.type='i01' order by c.id desc", + HomesReceptionCost.class) + .setParameter("rid", receptionId) + .setMaxResults(1) + .getResultList(); + // groupId filter too narrow - use any group + if (!list.isEmpty()) return list.get(0); + list = em.createQuery( + "select c from HomesReceptionCost c where c.receptionId=:rid and c.type='i01' order by c.id desc", + HomesReceptionCost.class) + .setParameter("rid", receptionId) + .setMaxResults(1) + .getResultList(); + return list.isEmpty() ? null : list.get(0); + } + + private String minusCategory(HomesReception r) { + String st = ReceptionStatuses.normalizeProgress(r.getProgressStatus()); + if ("f28".equals(st)) return "VOC"; + HomesReceptionCost prev = findPrevI01Cost(r.getId()); + if (prev != null && prev.getCostGroupId() != null) { + HomesReceptionCostGroup g = em.find(HomesReceptionCostGroup.class, prev.getCostGroupId()); + if (g != null && g.getTransferredAt() != null) return "송금완료 후 해지"; + } + return "개통완료 후 해지"; + } + + @SuppressWarnings("unchecked") + private List> castLines(Object raw) { + if (!(raw instanceof List list)) return List.of(); + List> out = new ArrayList<>(); + for (Object o : list) { + if (o instanceof Map m) out.add((Map) m); + else if (o instanceof Number n) out.add(Map.of("receptionId", n.longValue())); + } + return out; + } + + private List toLongList(Object raw) { + if (!(raw instanceof List list)) return List.of(); + return list.stream().map(this::toLong).filter(Objects::nonNull).collect(Collectors.toList()); + } + + private static int n(Integer v) { return v == null ? 0 : v; } + + private static int nz(Object v, BigDecimal fallback) { + if (v instanceof Number n) return n.intValue(); + if (v != null) { + try { return (int) Double.parseDouble(String.valueOf(v)); } catch (Exception ignored) {} + } + return fallback == null ? 0 : fallback.intValue(); + } + + private Long toLong(Object v) { + if (v instanceof Number n) return n.longValue(); + if (v == null) return null; + try { return Long.parseLong(String.valueOf(v)); } catch (Exception e) { return null; } + } + + private static String fmt(LocalDateTime t) { + return t == null ? null : t.format(DT); + } + + private static LocalDateTime parseDateTime(String s) { + if (blank(s)) return null; + try { + if (s.length() == 10) return LocalDate.parse(s).atStartOfDay(); + return LocalDateTime.parse(s.replace(' ', 'T')); + } catch (Exception e) { + return null; + } + } + + private static String str(Object v) { return v == null ? "" : String.valueOf(v).trim(); } + private static boolean blank(String s) { return s == null || s.isBlank(); } + private static String firstNonBlank(String... vals) { + for (String v : vals) if (!blank(v)) return v; + return ""; + } +} diff --git a/backend/src/main/java/com/bizcare/agent/CareConfigController.java b/backend/src/main/java/com/bizcare/agent/CareConfigController.java new file mode 100644 index 0000000..7a79e32 --- /dev/null +++ b/backend/src/main/java/com/bizcare/agent/CareConfigController.java @@ -0,0 +1,1417 @@ +package com.bizcare.agent; + +import com.fasterxml.jackson.databind.ObjectMapper; +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.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.*; + +/** + * 환경설정 — 통신사/입력처/게시판/수수료대상/퀵전송/기타설정/사용현황. + */ +@RestController +@RequestMapping("/api/agent/homes") +@RequiredArgsConstructor +class CareConfigController { + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + private static final DateTimeFormatter DAY = DateTimeFormatter.ofPattern("yyyy-MM-dd"); + + private final EntityManager em; + private final ObjectMapper mapper; + + // ==================== ISP (통신사관리) ==================== + + @GetMapping("/isp") + @Transactional(readOnly = true) + ApiResponse listIsp(@AuthenticationPrincipal JwtUser u) { + List list = em.createQuery( + "select e from HomesReceptionIsp e where e.groupId=:g order by e.seq asc, e.id asc", + HomesReceptionIsp.class) + .setParameter("g", u.getGroupId()).getResultList(); + return ApiResponse.ok(list.stream().map(this::ispMap).toList()); + } + + @GetMapping("/isp/{id}") + @Transactional(readOnly = true) + ApiResponse getIsp(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesReceptionIsp isp = findIsp(id, u); + if (isp == null) return ApiResponse.fail("통신사를 찾을 수 없습니다."); + return ApiResponse.ok(ispMap(isp)); + } + + @PostMapping("/isp") + @Transactional + ApiResponse createIsp(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + String telecom = str(body.get("telecom")); + String name = str(body.get("name")); + if (blank(name)) return ApiResponse.fail("통신사명을 입력하세요."); + int nextSeq = nextIspSeq(u.getGroupId()); + HomesReceptionIsp isp = new HomesReceptionIsp(); + isp.setGroupId(u.getGroupId()); + isp.setTelecom(blank(telecom) ? name : telecom); + isp.setName(name); + isp.setSeq(nextSeq); + isp.setActive(true); + isp.setIsSktb(false); + em.persist(isp); + return ApiResponse.ok(ispMap(isp)); + } + + @PutMapping("/isp/{id}") + @Transactional + ApiResponse updateIsp(@PathVariable Long id, @RequestBody Map body, + @AuthenticationPrincipal JwtUser u) { + HomesReceptionIsp isp = findIsp(id, u); + if (isp == null) return ApiResponse.fail("통신사를 찾을 수 없습니다."); + String name = str(body.get("name")); + if (blank(name)) return ApiResponse.fail("통신사명을 입력하세요."); + String telecom = str(body.get("telecom")); + isp.setName(name); + isp.setTelecom(blank(telecom) ? name : telecom); + if (body.containsKey("isSktb")) isp.setIsSktb(bool(body.get("isSktb"))); + return ApiResponse.ok(ispMap(isp)); + } + + @DeleteMapping("/isp/{id}") + @Transactional + ApiResponse deleteIsp(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesReceptionIsp isp = findIsp(id, u); + if (isp == null) return ApiResponse.fail("통신사를 찾을 수 없습니다."); + em.createQuery("delete from HomesReceptionIspProduct p where p.ispId=:id and p.groupId=:g") + .setParameter("id", id).setParameter("g", u.getGroupId()).executeUpdate(); + em.createQuery("delete from HomesReceptionIspAgreement a where a.ispId=:id and a.groupId=:g") + .setParameter("id", id).setParameter("g", u.getGroupId()).executeUpdate(); + em.remove(isp); + return ApiResponse.ok(null); + } + + @PostMapping("/isp/{id}/onoff") + @Transactional + ApiResponse toggleIsp(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesReceptionIsp isp = findIsp(id, u); + if (isp == null) return ApiResponse.fail("통신사를 찾을 수 없습니다."); + isp.setActive(!Boolean.TRUE.equals(isp.getActive())); + return ApiResponse.ok(ispMap(isp)); + } + + @PostMapping("/isp/{id}/updown/{dir}") + @Transactional + ApiResponse moveIsp(@PathVariable Long id, @PathVariable String dir, @AuthenticationPrincipal JwtUser u) { + HomesReceptionIsp isp = findIsp(id, u); + if (isp == null) return ApiResponse.fail("통신사를 찾을 수 없습니다."); + List list = em.createQuery( + "select e from HomesReceptionIsp e where e.groupId=:g order by e.seq asc, e.id asc", + HomesReceptionIsp.class) + .setParameter("g", u.getGroupId()).getResultList(); + if (!swapBySeq(list, isp.getId(), dir, HomesReceptionIsp::getId, + HomesReceptionIsp::getSeq, HomesReceptionIsp::setSeq)) { + return ApiResponse.fail("이동할 수 없습니다."); + } + return ApiResponse.ok(ispMap(isp)); + } + + @GetMapping("/isp/{ispId}/products") + @Transactional(readOnly = true) + ApiResponse ispProducts(@PathVariable Long ispId, @AuthenticationPrincipal JwtUser u) { + if (findIsp(ispId, u) == null) return ApiResponse.fail("통신사를 찾을 수 없습니다."); + List list = em.createQuery( + "select e from HomesReceptionIspProduct e where e.groupId=:g and e.ispId=:isp order by e.id", + HomesReceptionIspProduct.class) + .setParameter("g", u.getGroupId()).setParameter("isp", ispId).getResultList(); + return ApiResponse.ok(list.stream().map(p -> { + Map m = new LinkedHashMap<>(); + m.put("id", p.getId()); + m.put("ispId", p.getIspId()); + m.put("productType", p.getProductType()); + m.put("type", p.getProductType()); + m.put("name", p.getName()); + m.put("active", Boolean.TRUE.equals(p.getActive())); + return m; + }).toList()); + } + + @PostMapping("/isp/{ispId}/products") + @Transactional + ApiResponse createIspProduct(@PathVariable Long ispId, @RequestBody Map body, + @AuthenticationPrincipal JwtUser u) { + if (findIsp(ispId, u) == null) return ApiResponse.fail("통신사를 찾을 수 없습니다."); + String name = str(body.get("name")); + if (blank(name)) return ApiResponse.fail("상품명을 입력하세요."); + String type = firstNonBlank(str(body.get("productType")), str(body.get("type")), "e01"); + HomesReceptionIspProduct p = new HomesReceptionIspProduct(); + p.setGroupId(u.getGroupId()); + p.setIspId(ispId); + p.setProductType(type); + p.setName(name); + p.setActive(true); + em.persist(p); + return ApiResponse.ok(Map.of("id", p.getId(), "productType", p.getProductType(), + "type", p.getProductType(), "name", p.getName())); + } + + @PutMapping("/isp/products/{id}") + @Transactional + ApiResponse updateIspProduct(@PathVariable Long id, @RequestBody Map body, + @AuthenticationPrincipal JwtUser u) { + HomesReceptionIspProduct p = em.find(HomesReceptionIspProduct.class, id); + if (p == null || !Objects.equals(p.getGroupId(), u.getGroupId())) { + return ApiResponse.fail("상품을 찾을 수 없습니다."); + } + String name = str(body.get("name")); + if (!blank(name)) p.setName(name); + String type = firstNonBlank(str(body.get("productType")), str(body.get("type")), ""); + if (!blank(type)) p.setProductType(type); + return ApiResponse.ok(Map.of("id", p.getId(), "productType", nullToEmpty(p.getProductType()), + "type", nullToEmpty(p.getProductType()), "name", nullToEmpty(p.getName()))); + } + + @DeleteMapping("/isp/products/{id}") + @Transactional + ApiResponse deleteIspProduct(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesReceptionIspProduct p = em.find(HomesReceptionIspProduct.class, id); + if (p == null || !Objects.equals(p.getGroupId(), u.getGroupId())) { + return ApiResponse.fail("상품을 찾을 수 없습니다."); + } + em.remove(p); + return ApiResponse.ok(null); + } + + @GetMapping("/isp/{ispId}/agreements") + @Transactional(readOnly = true) + ApiResponse ispAgreements(@PathVariable Long ispId, @AuthenticationPrincipal JwtUser u) { + if (findIsp(ispId, u) == null) return ApiResponse.fail("통신사를 찾을 수 없습니다."); + List list = em.createQuery( + "select e from HomesReceptionIspAgreement e where e.groupId=:g and e.ispId=:isp order by e.id", + HomesReceptionIspAgreement.class) + .setParameter("g", u.getGroupId()).setParameter("isp", ispId).getResultList(); + return ApiResponse.ok(list.stream().map(a -> { + Map m = new LinkedHashMap<>(); + m.put("id", a.getId()); + m.put("ispId", a.getIspId()); + m.put("name", nullToEmpty(a.getName())); + m.put("active", Boolean.TRUE.equals(a.getActive())); + return m; + }).toList()); + } + + @PostMapping("/isp/{ispId}/agreements") + @Transactional + ApiResponse createIspAgreement(@PathVariable Long ispId, @RequestBody Map body, + @AuthenticationPrincipal JwtUser u) { + if (findIsp(ispId, u) == null) return ApiResponse.fail("통신사를 찾을 수 없습니다."); + String name = str(body.get("name")); + if (blank(name)) return ApiResponse.fail("약정명을 입력하세요."); + HomesReceptionIspAgreement a = new HomesReceptionIspAgreement(); + a.setGroupId(u.getGroupId()); + a.setIspId(ispId); + a.setName(name); + a.setActive(true); + em.persist(a); + return ApiResponse.ok(Map.of("id", a.getId(), "name", a.getName())); + } + + @PutMapping("/isp/agreements/{id}") + @Transactional + ApiResponse updateIspAgreement(@PathVariable Long id, @RequestBody Map body, + @AuthenticationPrincipal JwtUser u) { + HomesReceptionIspAgreement a = em.find(HomesReceptionIspAgreement.class, id); + if (a == null || !Objects.equals(a.getGroupId(), u.getGroupId())) { + return ApiResponse.fail("약정을 찾을 수 없습니다."); + } + String name = str(body.get("name")); + if (blank(name)) return ApiResponse.fail("약정명을 입력하세요."); + a.setName(name); + return ApiResponse.ok(Map.of("id", a.getId(), "name", a.getName())); + } + + @DeleteMapping("/isp/agreements/{id}") + @Transactional + ApiResponse deleteIspAgreement(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesReceptionIspAgreement a = em.find(HomesReceptionIspAgreement.class, id); + if (a == null || !Objects.equals(a.getGroupId(), u.getGroupId())) { + return ApiResponse.fail("약정을 찾을 수 없습니다."); + } + em.remove(a); + return ApiResponse.ok(null); + } + + // ==================== ISP Group ==================== + + @GetMapping("/isp-group") + @Transactional(readOnly = true) + ApiResponse listIspGroup(@AuthenticationPrincipal JwtUser u) { + List list = em.createQuery( + "select e from HomesIspGroup e where e.groupId=:g order by e.seq asc, e.id asc", + HomesIspGroup.class) + .setParameter("g", u.getGroupId()).getResultList(); + return ApiResponse.ok(list.stream().map(g -> { + Map m = new LinkedHashMap<>(); + m.put("id", g.getId()); + m.put("name", g.getName()); + m.put("seq", g.getSeq() == null ? 0 : g.getSeq()); + return m; + }).toList()); + } + + @PostMapping("/isp-group") + @Transactional + ApiResponse createIspGroup(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + String name = str(body.get("name")); + if (blank(name)) return ApiResponse.fail("그룹명을 입력하세요."); + HomesIspGroup g = new HomesIspGroup(); + g.setGroupId(u.getGroupId()); + g.setName(name); + g.setSeq(body.get("seq") instanceof Number n ? n.intValue() : nextIspGroupSeq(u.getGroupId())); + em.persist(g); + return ApiResponse.ok(Map.of("id", g.getId(), "name", g.getName(), "seq", g.getSeq())); + } + + @PutMapping("/isp-group/{id}") + @Transactional + ApiResponse updateIspGroup(@PathVariable Long id, @RequestBody Map body, + @AuthenticationPrincipal JwtUser u) { + HomesIspGroup g = findIspGroup(id, u); + if (g == null) return ApiResponse.fail("그룹을 찾을 수 없습니다."); + String name = str(body.get("name")); + if (blank(name)) return ApiResponse.fail("그룹명을 입력하세요."); + g.setName(name); + if (body.get("seq") instanceof Number n) g.setSeq(n.intValue()); + return ApiResponse.ok(Map.of("id", g.getId(), "name", g.getName(), "seq", g.getSeq())); + } + + @DeleteMapping("/isp-group/{id}") + @Transactional + ApiResponse deleteIspGroup(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesIspGroup g = findIspGroup(id, u); + if (g == null) return ApiResponse.fail("그룹을 찾을 수 없습니다."); + em.remove(g); + return ApiResponse.ok(null); + } + + // ==================== Input centers ==================== + + @GetMapping("/input-centers") + @Transactional(readOnly = true) + ApiResponse listInputCenters(@RequestParam(required = false) Boolean onlyOn, + @AuthenticationPrincipal JwtUser u) { + String jpql = "select e from HomesInputCenter e where e.groupId=:g" + + (Boolean.TRUE.equals(onlyOn) ? " and (e.active is null or e.active=true)" : "") + + " order by e.seq asc, e.id asc"; + List list = em.createQuery(jpql, HomesInputCenter.class) + .setParameter("g", u.getGroupId()).getResultList(); + return ApiResponse.ok(list.stream().map(this::centerMap).toList()); + } + + @GetMapping("/input-centers/{id}") + @Transactional(readOnly = true) + ApiResponse getInputCenter(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesInputCenter c = findCenter(id, u); + if (c == null) return ApiResponse.fail("입력처를 찾을 수 없습니다."); + return ApiResponse.ok(centerDetailMap(c)); + } + + @PostMapping("/input-centers") + @Transactional + ApiResponse createInputCenter(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + String name = str(body.get("name")); + if (blank(name)) return ApiResponse.fail("입력처명을 입력하세요."); + HomesInputCenter c = new HomesInputCenter(); + c.setGroupId(u.getGroupId()); + c.setName(name); + c.setTel(str(body.get("tel"))); + c.setPhone(str(body.get("phone"))); + c.setManager(str(body.get("manager"))); + c.setSeq(nextCenterSeq(u.getGroupId())); + c.setActive(true); + applyCenterExtra(c, body); + em.persist(c); + return ApiResponse.ok(centerDetailMap(c)); + } + + @PutMapping("/input-centers/{id}") + @Transactional + ApiResponse updateInputCenter(@PathVariable Long id, @RequestBody Map body, + @AuthenticationPrincipal JwtUser u) { + HomesInputCenter c = findCenter(id, u); + if (c == null) return ApiResponse.fail("입력처를 찾을 수 없습니다."); + String name = str(body.get("name")); + if (!blank(name)) c.setName(name); + if (body.containsKey("tel")) c.setTel(str(body.get("tel"))); + if (body.containsKey("phone")) c.setPhone(str(body.get("phone"))); + if (body.containsKey("manager")) c.setManager(str(body.get("manager"))); + applyCenterExtra(c, body); + return ApiResponse.ok(centerDetailMap(c)); + } + + @DeleteMapping("/input-centers/{id}") + @Transactional + ApiResponse deleteInputCenter(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesInputCenter c = findCenter(id, u); + if (c == null) return ApiResponse.fail("입력처를 찾을 수 없습니다."); + em.createQuery("delete from HomesCenterQuickAuth a where a.centerId=:id and a.groupId=:g") + .setParameter("id", id).setParameter("g", u.getGroupId()).executeUpdate(); + em.remove(c); + return ApiResponse.ok(null); + } + + @PostMapping("/input-centers/{id}/onoff") + @Transactional + ApiResponse toggleInputCenter(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesInputCenter c = findCenter(id, u); + if (c == null) return ApiResponse.fail("입력처를 찾을 수 없습니다."); + c.setActive(!Boolean.TRUE.equals(c.getActive())); + return ApiResponse.ok(centerMap(c)); + } + + @PostMapping("/input-centers/{id}/up") + @Transactional + ApiResponse moveCenterUp(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + return moveCenter(id, "up", u); + } + + @PostMapping("/input-centers/{id}/down") + @Transactional + ApiResponse moveCenterDown(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + return moveCenter(id, "down", u); + } + + // ==================== ISP Link ==================== + + @GetMapping("/isp-link") + @Transactional(readOnly = true) + ApiResponse listIspLinks(@AuthenticationPrincipal JwtUser u) { + List list = em.createQuery( + "select e from HomesIspLink e where e.groupId=:g order by e.seq asc, e.id asc", + HomesIspLink.class) + .setParameter("g", u.getGroupId()).getResultList(); + return ApiResponse.ok(list.stream().map(this::linkMap).toList()); + } + + @GetMapping("/isp-link/isp/{ispId}") + @Transactional(readOnly = true) + ApiResponse listIspLinksByIsp(@PathVariable Long ispId, @AuthenticationPrincipal JwtUser u) { + List list = em.createQuery( + "select e from HomesIspLink e where e.groupId=:g and e.ispId=:isp order by e.seq asc, e.id asc", + HomesIspLink.class) + .setParameter("g", u.getGroupId()).setParameter("isp", ispId).getResultList(); + return ApiResponse.ok(list.stream().map(this::linkMap).toList()); + } + + @PostMapping("/isp-link") + @Transactional + ApiResponse createIspLink(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + Long ispId = longOrNull(body.get("ispId")); + String name = str(body.get("name")); + String url = str(body.get("url")); + if (ispId == null) return ApiResponse.fail("통신사를 선택하세요."); + if (blank(name)) return ApiResponse.fail("링크명을 입력하세요."); + HomesIspLink link = new HomesIspLink(); + link.setGroupId(u.getGroupId()); + link.setIspId(ispId); + link.setName(name); + link.setUrl(url); + link.setSeq(nextLinkSeq(u.getGroupId(), ispId)); + link.setActive(true); + em.persist(link); + return ApiResponse.ok(linkMap(link)); + } + + @PutMapping("/isp-link/{id}") + @Transactional + ApiResponse updateIspLink(@PathVariable Long id, @RequestBody Map body, + @AuthenticationPrincipal JwtUser u) { + HomesIspLink link = findLink(id, u); + if (link == null) return ApiResponse.fail("링크를 찾을 수 없습니다."); + if (body.containsKey("name")) link.setName(str(body.get("name"))); + if (body.containsKey("url")) link.setUrl(str(body.get("url"))); + if (body.containsKey("ispId")) { + Long ispId = longOrNull(body.get("ispId")); + if (ispId != null) link.setIspId(ispId); + } + return ApiResponse.ok(linkMap(link)); + } + + @DeleteMapping("/isp-link/{id}") + @Transactional + ApiResponse deleteIspLink(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesIspLink link = findLink(id, u); + if (link == null) return ApiResponse.fail("링크를 찾을 수 없습니다."); + em.remove(link); + return ApiResponse.ok(null); + } + + @PostMapping("/isp-link/{id}/onoff") + @Transactional + ApiResponse toggleIspLink(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesIspLink link = findLink(id, u); + if (link == null) return ApiResponse.fail("링크를 찾을 수 없습니다."); + link.setActive(!Boolean.TRUE.equals(link.getActive())); + return ApiResponse.ok(linkMap(link)); + } + + @PostMapping("/isp-link/{id}/{dir}") + @Transactional + ApiResponse moveIspLink(@PathVariable Long id, @PathVariable String dir, + @AuthenticationPrincipal JwtUser u) { + HomesIspLink link = findLink(id, u); + if (link == null) return ApiResponse.fail("링크를 찾을 수 없습니다."); + if (!"up".equalsIgnoreCase(dir) && !"down".equalsIgnoreCase(dir)) { + return ApiResponse.fail("방향이 올바르지 않습니다."); + } + List list = em.createQuery( + "select e from HomesIspLink e where e.groupId=:g and e.ispId=:isp order by e.seq asc, e.id asc", + HomesIspLink.class) + .setParameter("g", u.getGroupId()).setParameter("isp", link.getIspId()).getResultList(); + if (!swapBySeq(list, link.getId(), dir, HomesIspLink::getId, + HomesIspLink::getSeq, HomesIspLink::setSeq)) { + return ApiResponse.fail("이동할 수 없습니다."); + } + return ApiResponse.ok(linkMap(link)); + } + + // ==================== Boards ==================== + + @GetMapping("/boards") + @Transactional(readOnly = true) + ApiResponse listBoards(@AuthenticationPrincipal JwtUser u) { + List list = em.createQuery( + "select e from HomesBoard e where e.groupId=:g order by e.seq asc, e.id asc", + HomesBoard.class) + .setParameter("g", u.getGroupId()).getResultList(); + return ApiResponse.ok(list.stream().map(this::boardMap).toList()); + } + + @PostMapping("/boards") + @Transactional + ApiResponse createBoard(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + String name = str(body.get("name")); + if (blank(name)) return ApiResponse.fail("게시판명을 입력하세요."); + HomesBoard b = new HomesBoard(); + b.setGroupId(u.getGroupId()); + b.setName(name); + b.setListLevel(intOr(body.get("listLevel"), 1)); + b.setReadLevel(intOr(body.get("readLevel"), 1)); + b.setWriteLevel(intOr(body.get("writeLevel"), 9)); + b.setSeq(nextBoardSeq(u.getGroupId())); + b.setActive(true); + em.persist(b); + return ApiResponse.ok(boardMap(b)); + } + + @PutMapping("/boards/{id}") + @Transactional + ApiResponse updateBoard(@PathVariable Long id, @RequestBody Map body, + @AuthenticationPrincipal JwtUser u) { + HomesBoard b = findBoard(id, u); + if (b == null) return ApiResponse.fail("게시판을 찾을 수 없습니다."); + if (body.containsKey("name")) { + String name = str(body.get("name")); + if (blank(name)) return ApiResponse.fail("게시판명을 입력하세요."); + b.setName(name); + } + if (body.containsKey("listLevel")) b.setListLevel(intOr(body.get("listLevel"), b.getListLevel())); + if (body.containsKey("readLevel")) b.setReadLevel(intOr(body.get("readLevel"), b.getReadLevel())); + if (body.containsKey("writeLevel")) b.setWriteLevel(intOr(body.get("writeLevel"), b.getWriteLevel())); + if (body.containsKey("seq")) b.setSeq(intOr(body.get("seq"), b.getSeq())); + if (body.containsKey("active")) b.setActive(bool(body.get("active"))); + return ApiResponse.ok(boardMap(b)); + } + + @DeleteMapping("/boards/{id}") + @Transactional + ApiResponse deleteBoard(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesBoard b = findBoard(id, u); + if (b == null) return ApiResponse.fail("게시판을 찾을 수 없습니다."); + em.remove(b); + return ApiResponse.ok(null); + } + + // ==================== Fee targets ==================== + + @GetMapping("/fee-targets/center") + @Transactional(readOnly = true) + ApiResponse feeTargetsCenter(@AuthenticationPrincipal JwtUser u) { + List list = em.createQuery( + "select e from HomesInputCenter e where e.groupId=:g and (e.active is null or e.active=true) order by e.seq asc, e.id asc", + HomesInputCenter.class) + .setParameter("g", u.getGroupId()).getResultList(); + return ApiResponse.ok(list.stream().map(c -> Map.of("id", c.getId(), "name", nullToEmpty(c.getName()))).toList()); + } + + @GetMapping("/fee-targets/group") + @Transactional(readOnly = true) + ApiResponse feeTargetsGroup(@AuthenticationPrincipal JwtUser u) { + List list = em.createQuery( + "select e from HomesBranchGroup e where e.groupId=:g order by e.seq asc, e.id asc", + HomesBranchGroup.class) + .setParameter("g", u.getGroupId()).getResultList(); + return ApiResponse.ok(list.stream().map(g -> Map.of("id", g.getId(), "name", nullToEmpty(g.getName()))).toList()); + } + + @GetMapping("/fee-targets/branch") + @Transactional(readOnly = true) + ApiResponse feeTargetsBranch(@AuthenticationPrincipal JwtUser u) { + List list = em.createQuery( + "select e from HomesBranch e where e.groupId=:g order by e.officeName asc, e.id asc", + HomesBranch.class) + .setParameter("g", u.getGroupId()).getResultList(); + return ApiResponse.ok(list.stream().map(b -> Map.of( + "id", b.getId(), + "name", nullToEmpty(b.getOfficeName()) + )).toList()); + } + + // ==================== Center fees ==================== + + @GetMapping("/center-fees/center/{centerId}") + @Transactional(readOnly = true) + ApiResponse listCenterFees(@PathVariable Long centerId, @AuthenticationPrincipal JwtUser u) { + HomesInputCenter c = findCenter(centerId, u); + if (c == null) return ApiResponse.fail("입력처를 찾을 수 없습니다."); + List list = em.createQuery( + "select f from HomesCenterFee f where f.groupId=:g and f.centerName=:n order by f.startDate desc, f.id desc", + HomesCenterFee.class) + .setParameter("g", u.getGroupId()).setParameter("n", c.getName()).getResultList(); + return ApiResponse.ok(list.stream().map(f -> { + Map m = new LinkedHashMap<>(); + m.put("id", f.getId()); + m.put("centerName", f.getCenterName()); + m.put("carrier", f.getCarrier()); + m.put("ispName", f.getCarrier()); + m.put("startDate", f.getStartDate() == null ? null : DT.format(f.getStartDate())); + m.put("memo", f.getMemo()); + return m; + }).toList()); + } + + @PostMapping("/center-fees/center/{centerId}") + @Transactional + ApiResponse createCenterFee(@PathVariable Long centerId, @RequestBody Map body, + @AuthenticationPrincipal JwtUser u) { + HomesInputCenter c = findCenter(centerId, u); + if (c == null) return ApiResponse.fail("입력처를 찾을 수 없습니다."); + String carrier = firstNonBlank(str(body.get("carrier")), str(body.get("ispName")), ""); + if (blank(carrier)) return ApiResponse.fail("통신사를 선택하세요."); + LocalDateTime start = parseDateTime(body.get("startDate")); + if (start == null) return ApiResponse.fail("적용일을 입력하세요."); + if (existsCenterFee(u.getGroupId(), c.getName(), carrier, start, null)) { + return ApiResponse.fail("이미 등록되어 있는 적용일입니다."); + } + HomesCenterFee fee = new HomesCenterFee(); + fee.setGroupId(u.getGroupId()); + fee.setCenterName(c.getName()); + fee.setCarrier(carrier); + fee.setStartDate(start); + fee.setMemo(str(body.get("memo"))); + em.persist(fee); + em.flush(); + replaceCenterProducts(fee.getId(), u.getGroupId(), body.get("products")); + return ApiResponse.ok(centerFeeDetailMap(fee)); + } + + @GetMapping("/center-fees/{feeId}") + @Transactional(readOnly = true) + ApiResponse centerFeeDetail(@PathVariable Long feeId, @AuthenticationPrincipal JwtUser u) { + HomesCenterFee f = em.find(HomesCenterFee.class, feeId); + if (f == null || !Objects.equals(f.getGroupId(), u.getGroupId())) { + return ApiResponse.fail("수수료를 찾을 수 없습니다."); + } + return ApiResponse.ok(centerFeeDetailMap(f)); + } + + @PutMapping("/center-fees/{feeId}") + @Transactional + ApiResponse updateCenterFee(@PathVariable Long feeId, @RequestBody Map body, + @AuthenticationPrincipal JwtUser u) { + HomesCenterFee f = em.find(HomesCenterFee.class, feeId); + if (f == null || !Objects.equals(f.getGroupId(), u.getGroupId())) { + return ApiResponse.fail("수수료를 찾을 수 없습니다."); + } + LocalDateTime start = parseDateTime(body.get("startDate")); + if (start == null) return ApiResponse.fail("적용일을 입력하세요."); + String carrier = firstNonBlank(str(body.get("carrier")), str(body.get("ispName")), f.getCarrier()); + if (existsCenterFee(u.getGroupId(), f.getCenterName(), carrier, start, feeId)) { + return ApiResponse.fail("이미 등록되어 있는 적용일입니다."); + } + f.setCarrier(carrier); + f.setStartDate(start); + if (body.containsKey("memo")) f.setMemo(str(body.get("memo"))); + replaceCenterProducts(feeId, u.getGroupId(), body.get("products")); + return ApiResponse.ok(centerFeeDetailMap(f)); + } + + @DeleteMapping("/center-fees/{feeId}") + @Transactional + ApiResponse deleteCenterFee(@PathVariable Long feeId, @AuthenticationPrincipal JwtUser u) { + HomesCenterFee f = em.find(HomesCenterFee.class, feeId); + if (f == null || !Objects.equals(f.getGroupId(), u.getGroupId())) { + return ApiResponse.fail("수수료를 찾을 수 없습니다."); + } + em.createQuery("delete from HomesCenterFeeProduct p where p.feeId=:fid and p.groupId=:gid") + .setParameter("fid", feeId).setParameter("gid", u.getGroupId()).executeUpdate(); + em.remove(f); + return ApiResponse.ok(null); + } + + // ==================== Branch group fees ==================== + + @GetMapping("/branch-groups/{id}/fees") + @Transactional(readOnly = true) + ApiResponse listGroupFees(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesBranchGroup g = findBranchGroup(id, u); + if (g == null) return ApiResponse.fail("영업점 그룹을 찾을 수 없습니다."); + List list = em.createQuery( + "select f from HomesBranchFee f where f.groupId=:gid and f.feeType='group' and f.branchGroupName=:name order by f.startDate desc, f.id desc", + HomesBranchFee.class) + .setParameter("gid", u.getGroupId()).setParameter("name", g.getName()).getResultList(); + return ApiResponse.ok(list.stream().map(f -> { + Map m = new LinkedHashMap<>(); + m.put("id", f.getId()); + m.put("type", "group"); + m.put("ispName", f.getCarrier()); + m.put("carrier", f.getCarrier()); + m.put("category", "공통"); + m.put("startDate", f.getStartDate() == null ? null : DT.format(f.getStartDate())); + m.put("branchGroupName", f.getBranchGroupName()); + m.put("memo", f.getMemo()); + return m; + }).toList()); + } + + @PostMapping("/branch-groups/{id}/fees") + @Transactional + ApiResponse createGroupFee(@PathVariable Long id, @RequestBody Map body, + @AuthenticationPrincipal JwtUser u) { + HomesBranchGroup g = findBranchGroup(id, u); + if (g == null) return ApiResponse.fail("영업점 그룹을 찾을 수 없습니다."); + String carrier = firstNonBlank(str(body.get("carrier")), str(body.get("ispName")), ""); + if (blank(carrier)) return ApiResponse.fail("통신사를 선택하세요."); + LocalDateTime start = parseDateTime(body.get("startDate")); + if (start == null) return ApiResponse.fail("적용일을 입력하세요."); + if (existsGroupFee(u.getGroupId(), g.getName(), carrier, start, null)) { + return ApiResponse.fail("이미 등록되어 있는 적용일입니다."); + } + HomesBranchFee fee = new HomesBranchFee(); + fee.setGroupId(u.getGroupId()); + fee.setFeeType("group"); + fee.setBranchGroupName(g.getName()); + fee.setCarrier(carrier); + fee.setStartDate(start); + fee.setMemo(str(body.get("memo"))); + em.persist(fee); + em.flush(); + replaceBranchProducts(fee.getId(), u.getGroupId(), body.get("products")); + return ApiResponse.ok(branchFeeDetailMap(fee)); + } + + @GetMapping("/branch-group-fees/{feeId}") + @Transactional(readOnly = true) + ApiResponse groupFeeDetail(@PathVariable Long feeId, @AuthenticationPrincipal JwtUser u) { + HomesBranchFee f = em.find(HomesBranchFee.class, feeId); + if (f == null || !Objects.equals(f.getGroupId(), u.getGroupId()) || !"group".equals(f.getFeeType())) { + return ApiResponse.fail("그룹 수수료를 찾을 수 없습니다."); + } + return ApiResponse.ok(branchFeeDetailMap(f)); + } + + @PutMapping("/branch-group-fees/{feeId}") + @Transactional + ApiResponse updateGroupFee(@PathVariable Long feeId, @RequestBody Map body, + @AuthenticationPrincipal JwtUser u) { + HomesBranchFee f = em.find(HomesBranchFee.class, feeId); + if (f == null || !Objects.equals(f.getGroupId(), u.getGroupId()) || !"group".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() + : firstNonBlank(str(body.get("carrier")), str(body.get("ispName")), f.getCarrier()); + if (existsGroupFee(u.getGroupId(), f.getBranchGroupName(), carrier, start, feeId)) { + return ApiResponse.fail("이미 등록되어 있는 적용일입니다."); + } + f.setCarrier(carrier); + f.setStartDate(start); + f.setMemo(str(body.get("memo"))); + replaceBranchProducts(feeId, u.getGroupId(), body.get("products")); + return ApiResponse.ok(branchFeeDetailMap(f)); + } + + @DeleteMapping("/branch-group-fees/{feeId}") + @Transactional + ApiResponse deleteGroupFee(@PathVariable Long feeId, @AuthenticationPrincipal JwtUser u) { + HomesBranchFee f = em.find(HomesBranchFee.class, feeId); + if (f == null || !Objects.equals(f.getGroupId(), u.getGroupId()) || !"group".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); + } + + // ==================== Quick delivery ==================== + + @GetMapping("/quick-delivery/centers") + @Transactional(readOnly = true) + ApiResponse quickDeliveryCenters(@AuthenticationPrincipal JwtUser u) { + List centers = em.createQuery( + "select e from HomesInputCenter e where e.groupId=:g order by e.seq asc, e.id asc", + HomesInputCenter.class) + .setParameter("g", u.getGroupId()).getResultList(); + Map authByCenter = new HashMap<>(); + for (HomesCenterQuickAuth a : em.createQuery( + "select a from HomesCenterQuickAuth a where a.groupId=:g", HomesCenterQuickAuth.class) + .setParameter("g", u.getGroupId()).getResultList()) { + authByCenter.put(a.getCenterId(), a); + } + List> out = new ArrayList<>(); + for (HomesInputCenter c : centers) { + HomesCenterQuickAuth auth = authByCenter.get(c.getId()); + Map m = new LinkedHashMap<>(); + m.put("id", c.getId()); + m.put("name", c.getName()); + m.put("tel", c.getTel()); + m.put("manager", c.getManager()); + m.put("active", Boolean.TRUE.equals(c.getActive())); + m.put("authorized", auth != null && Boolean.TRUE.equals(auth.getAuthorized())); + m.put("userid", auth == null ? null : auth.getUserid()); + m.put("baseUrl", auth == null ? null : auth.getBaseUrl()); + m.put("hasAuth", auth != null); + out.add(m); + } + return ApiResponse.ok(out); + } + + @PostMapping("/quick-delivery/center/{id}/auth") + @Transactional + ApiResponse quickAuth(@PathVariable Long id, @RequestBody Map body, + @AuthenticationPrincipal JwtUser u) { + HomesInputCenter c = findCenter(id, u); + if (c == null) return ApiResponse.fail("입력처를 찾을 수 없습니다."); + String userid = str(body.get("userid")); + String password = str(body.get("password")); + String baseUrl = str(body.get("baseUrl")); + if (blank(userid) || blank(password)) return ApiResponse.fail("아이디와 비밀번호를 입력하세요."); + HomesCenterQuickAuth auth = findQuickAuth(id, u.getGroupId()); + if (auth == null) { + auth = new HomesCenterQuickAuth(); + auth.setGroupId(u.getGroupId()); + auth.setCenterId(id); + em.persist(auth); + } + auth.setUserid(userid); + auth.setPasswordEnc(encodePassword(password)); + auth.setBaseUrl(baseUrl); + auth.setAuthorized(true); + return ApiResponse.ok(Map.of( + "centerId", id, + "userid", userid, + "baseUrl", baseUrl, + "authorized", true + )); + } + + @PostMapping("/quick-delivery/center/{id}/reauth") + @Transactional + ApiResponse quickReauth(@PathVariable Long id, @RequestBody Map body, + @AuthenticationPrincipal JwtUser u) { + HomesInputCenter c = findCenter(id, u); + if (c == null) return ApiResponse.fail("입력처를 찾을 수 없습니다."); + HomesCenterQuickAuth auth = findQuickAuth(id, u.getGroupId()); + if (auth == null) return ApiResponse.fail("인증 정보가 없습니다."); + String userid = str(body.get("userid")); + String password = str(body.get("password")); + if (blank(userid) || blank(password)) return ApiResponse.fail("아이디와 비밀번호를 입력하세요."); + auth.setUserid(userid); + auth.setPasswordEnc(encodePassword(password)); + auth.setAuthorized(true); + return ApiResponse.ok(Map.of("centerId", id, "userid", userid, "authorized", true)); + } + + @DeleteMapping("/quick-delivery/center/{id}/auth") + @Transactional + ApiResponse deleteQuickAuth(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) { + HomesCenterQuickAuth auth = findQuickAuth(id, u.getGroupId()); + if (auth == null) return ApiResponse.fail("인증 정보가 없습니다."); + em.remove(auth); + return ApiResponse.ok(null); + } + + @GetMapping("/quick-delivery/logs") + @Transactional(readOnly = true) + ApiResponse quickDeliveryLogs(@RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @AuthenticationPrincipal JwtUser u) { + int safeSize = Math.min(Math.max(size, 1), 200); + int safePage = Math.max(page, 0); + Long total = em.createQuery( + "select count(e) from HomesQuickDeliveryLog e where e.groupId=:g", Long.class) + .setParameter("g", u.getGroupId()).getSingleResult(); + List list = em.createQuery( + "select e from HomesQuickDeliveryLog e where e.groupId=:g order by e.id desc", + HomesQuickDeliveryLog.class) + .setParameter("g", u.getGroupId()) + .setFirstResult(safePage * safeSize) + .setMaxResults(safeSize) + .getResultList(); + List> content = list.stream().map(log -> { + Map m = new LinkedHashMap<>(); + m.put("id", log.getId()); + m.put("name", log.getName()); + m.put("status", log.getStatus()); + m.put("memo", log.getMemo()); + m.put("orderQuickDeliveryClientId", log.getOrderQuickDeliveryClientId()); + m.put("createdAt", log.getCreatedAt() == null ? null + : DT.format(log.getCreatedAt().atZone(KST).toLocalDateTime())); + return m; + }).toList(); + boolean last = (long) (safePage + 1) * safeSize >= total; + Map out = new LinkedHashMap<>(); + out.put("content", content); + out.put("last", last); + out.put("totalElements", total); + out.put("page", safePage); + out.put("size", safeSize); + return ApiResponse.ok(out); + } + + // ==================== Configure ==================== + + @GetMapping("/configure") + @Transactional(readOnly = true) + ApiResponse getConfigure(@AuthenticationPrincipal JwtUser u) { + return ApiResponse.ok(loadConfigure(u.getGroupId())); + } + + @PostMapping("/configure") + @Transactional + ApiResponse saveConfigure(@RequestBody Map body, @AuthenticationPrincipal JwtUser u) { + Map current = loadConfigure(u.getGroupId()); + if (body != null) { + for (Map.Entry e : body.entrySet()) { + if (e.getKey() != null) current.put(e.getKey(), e.getValue() == null ? "" : e.getValue()); + } + } + saveConfigure(u.getGroupId(), current); + return ApiResponse.ok(current); + } + + @PostMapping("/configure/toggle/{key}") + @Transactional + ApiResponse toggleConfigure(@PathVariable String key, @AuthenticationPrincipal JwtUser u) { + Map current = loadConfigure(u.getGroupId()); + Object raw = current.get(key); + boolean next = !isTruthy(raw); + current.put(key, next ? "true" : "false"); + saveConfigure(u.getGroupId(), current); + return ApiResponse.ok(current); + } + + // ==================== Access log report ==================== + + @GetMapping("/access-log/report") + @Transactional(readOnly = true) + ApiResponse accessLogReport(@AuthenticationPrincipal JwtUser u) { + LocalDateTime lastLogin = null; + try { + lastLogin = em.createQuery( + "select max(m.lastLoginAt) from Member m where m.groupId=:g", LocalDateTime.class) + .setParameter("g", u.getGroupId()).getSingleResult(); + } catch (Exception ignored) { /* none */ } + + LocalDateTime lastOrder = null; + try { + lastOrder = em.createQuery( + "select max(r.orderDate) from HomesReception r where r.groupId=:g", LocalDateTime.class) + .setParameter("g", u.getGroupId()).getSingleResult(); + } catch (Exception ignored) { /* none */ } + if (lastOrder == null) { + try { + Instant maxCreated = em.createQuery( + "select max(r.createdAt) from HomesReception r where r.groupId=:g", Instant.class) + .setParameter("g", u.getGroupId()).getSingleResult(); + if (maxCreated != null) lastOrder = LocalDateTime.ofInstant(maxCreated, KST); + } catch (Exception ignored) { /* none */ } + } + + LocalDate today = LocalDate.now(KST); + List> daily = new ArrayList<>(); + for (int i = 6; i >= 0; i--) { + LocalDate day = today.minusDays(i); + LocalDateTime from = day.atStartOfDay(); + LocalDateTime to = day.plusDays(1).atStartOfDay(); + Long cnt = em.createQuery( + "select count(r) from HomesReception r where r.groupId=:g and r.orderDate>=:from and r.orderDate<:to", + Long.class) + .setParameter("g", u.getGroupId()) + .setParameter("from", from) + .setParameter("to", to) + .getSingleResult(); + Map row = new LinkedHashMap<>(); + row.put("date", DAY.format(day)); + row.put("pageViews", cnt == null ? 0 : cnt); + daily.add(row); + } + + Map out = new LinkedHashMap<>(); + out.put("lastLogin", lastLogin == null ? null : DT.format(lastLogin)); + out.put("lastOrderCreated", lastOrder == null ? null : DT.format(lastOrder)); + out.put("dailyPageViews", daily); + return ApiResponse.ok(out); + } + + // ==================== helpers ==================== + + private Map ispMap(HomesReceptionIsp isp) { + Map m = new LinkedHashMap<>(); + m.put("id", isp.getId()); + m.put("telecom", isp.getTelecom()); + m.put("name", isp.getName()); + m.put("seq", isp.getSeq() == null ? 0 : isp.getSeq()); + m.put("isOn", Boolean.TRUE.equals(isp.getActive()) ? 1 : 0); + m.put("active", Boolean.TRUE.equals(isp.getActive())); + m.put("isSktb", Boolean.TRUE.equals(isp.getIsSktb())); + return m; + } + + private Map centerMap(HomesInputCenter c) { + Map m = new LinkedHashMap<>(); + m.put("id", c.getId()); + m.put("name", c.getName()); + m.put("tel", c.getTel()); + m.put("phone", c.getPhone()); + m.put("manager", c.getManager()); + m.put("seq", c.getSeq() == null ? 0 : c.getSeq()); + m.put("active", Boolean.TRUE.equals(c.getActive())); + m.put("isOn", Boolean.TRUE.equals(c.getActive()) ? 1 : 0); + return m; + } + + private Map centerDetailMap(HomesInputCenter c) { + Map m = centerMap(c); + m.put("accountBank", c.getAccountBank()); + m.put("accountNumber", c.getAccountNumber()); + m.put("accountName", c.getAccountName()); + m.put("memo", c.getMemo()); + m.put("file1", c.getFile1()); + m.put("file2", c.getFile2()); + m.put("file3", c.getFile3()); + m.put("file4", c.getFile4()); + m.put("file5", c.getFile5()); + return m; + } + + private void applyCenterExtra(HomesInputCenter c, Map body) { + if (body.containsKey("accountBank")) c.setAccountBank(str(body.get("accountBank"))); + if (body.containsKey("accountNumber")) c.setAccountNumber(str(body.get("accountNumber"))); + if (body.containsKey("accountName")) c.setAccountName(str(body.get("accountName"))); + if (body.containsKey("memo")) c.setMemo(str(body.get("memo"))); + if (body.containsKey("file1")) c.setFile1(str(body.get("file1"))); + if (body.containsKey("file2")) c.setFile2(str(body.get("file2"))); + if (body.containsKey("file3")) c.setFile3(str(body.get("file3"))); + if (body.containsKey("file4")) c.setFile4(str(body.get("file4"))); + if (body.containsKey("file5")) c.setFile5(str(body.get("file5"))); + } + + private Map linkMap(HomesIspLink link) { + Map m = new LinkedHashMap<>(); + m.put("id", link.getId()); + m.put("ispId", link.getIspId()); + m.put("name", link.getName()); + m.put("url", link.getUrl()); + m.put("seq", link.getSeq() == null ? 0 : link.getSeq()); + m.put("active", Boolean.TRUE.equals(link.getActive())); + m.put("isOn", Boolean.TRUE.equals(link.getActive()) ? 1 : 0); + return m; + } + + private Map boardMap(HomesBoard b) { + Map m = new LinkedHashMap<>(); + m.put("id", b.getId()); + m.put("name", b.getName()); + m.put("listLevel", b.getListLevel() == null ? 1 : b.getListLevel()); + m.put("readLevel", b.getReadLevel() == null ? 1 : b.getReadLevel()); + m.put("writeLevel", b.getWriteLevel() == null ? 9 : b.getWriteLevel()); + m.put("seq", b.getSeq() == null ? 0 : b.getSeq()); + m.put("active", Boolean.TRUE.equals(b.getActive())); + return m; + } + + private Map centerFeeDetailMap(HomesCenterFee f) { + List products = em.createQuery( + "select p from HomesCenterFeeProduct p where p.feeId=:fid and p.groupId=:gid order by p.productType, p.id", + HomesCenterFeeProduct.class) + .setParameter("fid", f.getId()).setParameter("gid", f.getGroupId()).getResultList(); + Map fee = new LinkedHashMap<>(); + fee.put("id", f.getId()); + fee.put("centerName", f.getCenterName()); + 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()); + List> productMaps = new ArrayList<>(); + for (HomesCenterFeeProduct p : products) { + Map 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 out = new LinkedHashMap<>(); + out.put("fee", fee); + out.put("products", productMaps); + return out; + } + + private Map branchFeeDetailMap(HomesBranchFee f) { + List 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 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("source", String.valueOf(f.getId())); + List> productMaps = new ArrayList<>(); + for (HomesBranchFeeProduct p : products) { + Map 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 out = new LinkedHashMap<>(); + out.put("fee", fee); + out.put("products", productMaps); + return out; + } + + private void replaceCenterProducts(Long feeId, String groupId, Object productsRaw) { + em.createQuery("delete from HomesCenterFeeProduct 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; + HomesCenterFeeProduct p = new HomesCenterFeeProduct(); + p.setGroupId(groupId); + p.setFeeId(feeId); + p.setProductType(blank(productType) ? "e01" : productType); + p.setProductName(productName); + p.setFee(parseFee(m.get("fee"))); + em.persist(p); + } + } + + private void replaceBranchProducts(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); + p.setFee(parseFee(m.get("fee"))); + em.persist(p); + } + } + + private int parseFee(Object feeObj) { + 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 */ } + return fee; + } + + private boolean existsCenterFee(String gid, String centerName, String carrier, LocalDateTime start, Long excludeId) { + String jpql = "select count(f) from HomesCenterFee f where f.groupId=:gid and f.centerName=: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", centerName) + .setParameter("c", carrier).setParameter("sd", start); + if (excludeId != null) q.setParameter("id", excludeId); + return q.getSingleResult() > 0; + } + + private boolean existsGroupFee(String gid, String groupName, String carrier, LocalDateTime start, Long excludeId) { + String jpql = "select count(f) from HomesBranchFee f where f.groupId=:gid and f.feeType='group'" + + " and f.branchGroupName=: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", groupName) + .setParameter("c", carrier).setParameter("sd", start); + if (excludeId != null) q.setParameter("id", excludeId); + return q.getSingleResult() > 0; + } + + private ApiResponse moveCenter(Long id, String dir, JwtUser u) { + HomesInputCenter c = findCenter(id, u); + if (c == null) return ApiResponse.fail("입력처를 찾을 수 없습니다."); + List list = em.createQuery( + "select e from HomesInputCenter e where e.groupId=:g order by e.seq asc, e.id asc", + HomesInputCenter.class) + .setParameter("g", u.getGroupId()).getResultList(); + if (!swapBySeq(list, c.getId(), dir, HomesInputCenter::getId, + HomesInputCenter::getSeq, HomesInputCenter::setSeq)) { + return ApiResponse.fail("이동할 수 없습니다."); + } + return ApiResponse.ok(centerMap(c)); + } + + private interface SeqGetter { Integer get(T t); } + private interface SeqSetter { void set(T t, Integer v); } + private interface IdGetter { Long get(T t); } + + private boolean swapBySeq(List list, Long id, String dir, + IdGetter idGet, SeqGetter seqGet, SeqSetter seqSet) { + int idx = -1; + for (int i = 0; i < list.size(); i++) { + if (Objects.equals(idGet.get(list.get(i)), id)) { idx = i; break; } + } + if (idx < 0) return false; + int swapIdx = "up".equalsIgnoreCase(dir) ? idx - 1 : idx + 1; + if (swapIdx < 0 || swapIdx >= list.size()) return false; + T a = list.get(idx); + T b = list.get(swapIdx); + Integer sa = seqGet.get(a) == null ? idx : seqGet.get(a); + Integer sb = seqGet.get(b) == null ? swapIdx : seqGet.get(b); + seqSet.set(a, sb); + seqSet.set(b, sa); + return true; + } + + private HomesReceptionIsp findIsp(Long id, JwtUser u) { + HomesReceptionIsp isp = em.find(HomesReceptionIsp.class, id); + if (isp == null || !Objects.equals(isp.getGroupId(), u.getGroupId())) return null; + return isp; + } + + private HomesIspGroup findIspGroup(Long id, JwtUser u) { + HomesIspGroup g = em.find(HomesIspGroup.class, id); + if (g == null || !Objects.equals(g.getGroupId(), u.getGroupId())) return null; + return g; + } + + private HomesInputCenter findCenter(Long id, JwtUser u) { + HomesInputCenter c = em.find(HomesInputCenter.class, id); + if (c == null || !Objects.equals(c.getGroupId(), u.getGroupId())) return null; + return c; + } + + private HomesIspLink findLink(Long id, JwtUser u) { + HomesIspLink link = em.find(HomesIspLink.class, id); + if (link == null || !Objects.equals(link.getGroupId(), u.getGroupId())) return null; + return link; + } + + private HomesBoard findBoard(Long id, JwtUser u) { + HomesBoard b = em.find(HomesBoard.class, id); + if (b == null || !Objects.equals(b.getGroupId(), u.getGroupId())) return null; + return b; + } + + private HomesBranchGroup findBranchGroup(Long id, JwtUser u) { + HomesBranchGroup g = em.find(HomesBranchGroup.class, id); + if (g == null || !Objects.equals(g.getGroupId(), u.getGroupId())) return null; + return g; + } + + private HomesCenterQuickAuth findQuickAuth(Long centerId, String groupId) { + List list = em.createQuery( + "select a from HomesCenterQuickAuth a where a.groupId=:g and a.centerId=:c", + HomesCenterQuickAuth.class) + .setParameter("g", groupId).setParameter("c", centerId) + .setMaxResults(1).getResultList(); + return list.isEmpty() ? null : list.getFirst(); + } + + private int nextIspSeq(String groupId) { + Integer max = em.createQuery( + "select max(e.seq) from HomesReceptionIsp e where e.groupId=:g", Integer.class) + .setParameter("g", groupId).getSingleResult(); + return max == null ? 1 : max + 1; + } + + private int nextIspGroupSeq(String groupId) { + Integer max = em.createQuery( + "select max(e.seq) from HomesIspGroup e where e.groupId=:g", Integer.class) + .setParameter("g", groupId).getSingleResult(); + return max == null ? 1 : max + 1; + } + + private int nextCenterSeq(String groupId) { + Integer max = em.createQuery( + "select max(e.seq) from HomesInputCenter e where e.groupId=:g", Integer.class) + .setParameter("g", groupId).getSingleResult(); + return max == null ? 1 : max + 1; + } + + private int nextLinkSeq(String groupId, Long ispId) { + Integer max = em.createQuery( + "select max(e.seq) from HomesIspLink e where e.groupId=:g and e.ispId=:isp", Integer.class) + .setParameter("g", groupId).setParameter("isp", ispId).getSingleResult(); + return max == null ? 1 : max + 1; + } + + private int nextBoardSeq(String groupId) { + Integer max = em.createQuery( + "select max(e.seq) from HomesBoard e where e.groupId=:g", Integer.class) + .setParameter("g", groupId).getSingleResult(); + return max == null ? 1 : max + 1; + } + + @SuppressWarnings("unchecked") + private Map loadConfigure(String groupId) { + Map def = defaultConfigure(); + List rows = em.createQuery( + "select s from Setting s where s.groupId=:g and s.settingKey=:k", Setting.class) + .setParameter("g", groupId).setParameter("k", "homes_configure").getResultList(); + if (rows.isEmpty() || rows.getFirst().getValue() == null || rows.getFirst().getValue().isBlank()) { + return def; + } + try { + Map loaded = mapper.readValue(rows.getFirst().getValue(), Map.class); + for (Map.Entry e : loaded.entrySet()) { + def.put(e.getKey(), e.getValue()); + } + return def; + } catch (Exception e) { + return def; + } + } + + private void saveConfigure(String groupId, Map value) { + List rows = em.createQuery( + "select s from Setting s where s.groupId=:g and s.settingKey=:k", Setting.class) + .setParameter("g", groupId).setParameter("k", "homes_configure").getResultList(); + try { + String json = mapper.writeValueAsString(value); + if (rows.isEmpty()) { + Setting s = new Setting(); + s.setGroupId(groupId); + s.setSettingKey("homes_configure"); + s.setValue(json); + em.persist(s); + } else { + rows.getFirst().setValue(json); + } + } catch (Exception e) { + throw new IllegalArgumentException("설정 저장 실패"); + } + } + + private static Map defaultConfigure() { + Map m = new LinkedHashMap<>(); + m.put("sitename", ""); + m.put("tel", ""); + m.put("fax", ""); + m.put("sms_callback", ""); + m.put("excel_down_email", ""); + m.put("logo_path", ""); + m.put("signup_sms_destno", ""); + m.put("signup_sms_message", ""); + m.put("order_created_sms_destno", ""); + m.put("order_created_sms_message", ""); + m.put("branch_granted_sms", ""); + m.put("branch_granted_message", ""); + m.put("ip_list", ""); + m.put("enable_ip_userid", "false"); + m.put("agreement", ""); + m.put("login_ui_apply", "false"); + m.put("login_ui", ""); + m.put("company_key", ""); + m.put("manager_url", ""); + m.put("order_disabled", "false"); + m.put("personal_info_conceal", "false"); + m.put("personal_info_conceal_in_order_list", "false"); + m.put("exceldown_button_hide", "false"); + m.put("gift_button_hide", "false"); + return m; + } + + private static boolean isTruthy(Object raw) { + if (raw == null) return false; + if (raw instanceof Boolean b) return b; + String s = String.valueOf(raw).trim(); + return "true".equalsIgnoreCase(s) || "1".equals(s) || "Y".equalsIgnoreCase(s) || "on".equalsIgnoreCase(s); + } + + private static String encodePassword(String password) { + return Base64.getEncoder().encodeToString(password.getBytes(StandardCharsets.UTF_8)); + } + + 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 static int intOr(Object o, Integer fallback) { + if (o instanceof Number n) return n.intValue(); + if (o != null && !String.valueOf(o).isBlank()) { + try { return Integer.parseInt(String.valueOf(o).trim()); } catch (Exception ignored) { /* fallback */ } + } + return fallback == null ? 0 : fallback; + } + + private static String firstNonBlank(String... values) { + for (String v : values) { + if (!blank(v)) return v; + } + return ""; + } + + private static String nullToEmpty(String s) { return s == null ? "" : s; } + 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)) + || "1".equals(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; } + } +} diff --git a/backend/src/main/java/com/bizcare/agent/CareFeeController.java b/backend/src/main/java/com/bizcare/agent/CareFeeController.java new file mode 100644 index 0000000..181546c --- /dev/null +++ b/backend/src/main/java/com/bizcare/agent/CareFeeController.java @@ -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 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 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 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 toBranchView(HomesBranchFee fee, String type, String entityName, String carrier) { + Map 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 toCenterView(HomesCenterFee fee, String entityName, String carrier) { + Map 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> 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 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> 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 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(); } +} diff --git a/backend/src/main/java/com/bizcare/agent/CareHomesController.java b/backend/src/main/java/com/bizcare/agent/CareHomesController.java index 3fa6271..0270456 100644 --- a/backend/src/main/java/com/bizcare/agent/CareHomesController.java +++ b/backend/src/main/java/com/bizcare/agent/CareHomesController.java @@ -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 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 parseMenuFlagsMap(String raw) { Map 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> LEVEL_ROWS; static { Map> 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; }; diff --git a/backend/src/main/java/com/bizcare/agent/CareReceptionController.java b/backend/src/main/java/com/bizcare/agent/CareReceptionController.java index b044221..55195de 100644 --- a/backend/src/main/java/com/bizcare/agent/CareReceptionController.java +++ b/backend/src/main/java/com/bizcare/agent/CareReceptionController.java @@ -29,10 +29,9 @@ class CareReceptionController { private final EntityManager em; private final FileStorageService fileStorage; - static final List PROGRESS_STATUSES = List.of( - "1.접수완료", "2.고객통화", "3.서류검토", "4.개통완료", "5.사은품지급", "6.정산완료", "9.취소"); - static final List SETTLE_STATUSES = List.of("정산대기", "정산완료", "회수대기", "회수완료"); - static final List GIFT_STATUSES = List.of("미지급", "지급대기", "지급완료", "반송"); + static final List PROGRESS_STATUSES = ReceptionStatuses.progressCodes(); + static final List SETTLE_STATUSES = ReceptionStatuses.centerCodes(); + static final List GIFT_STATUSES = ReceptionStatuses.giftCodes(); static final List CARRIERS = List.of("KT", "SKB", "LGU+", "SKT", "기타"); static final List REGIONS = List.of( "서울", "경기", "인천", "강원", "충북", "충남", "대전", "전북", "전남", "광주", @@ -47,7 +46,9 @@ class CareReceptionController { Map 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 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 statusOptions = new LinkedHashMap<>(); - for (String s : PROGRESS_STATUSES) statusOptions.put(s, s); - data.put("statusOptions", statusOptions); - Map 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); diff --git a/backend/src/main/java/com/bizcare/agent/DataInitializer.java b/backend/src/main/java/com/bizcare/agent/DataInitializer.java index 972b036..e650b01 100644 --- a/backend/src/main/java/com/bizcare/agent/DataInitializer.java +++ b/backend/src/main/java/com/bizcare/agent/DataInitializer.java @@ -71,8 +71,28 @@ class DataInitializer implements CommandLineRunner { seedScheduleSampleIfEmpty(); seedPostitsIfEmpty(); seedStockInfoSampleIfEmpty(); + migrateReceptionStatusesToCodes(); + seedCalculateSamplesIfEmpty(); + seedFeeSheetsIfEmpty(); + seedBranchesIfEmpty(); } + /** 구 한글 진행/사은품/입력처 상태를 billing 코드(f/g/h)로 변환 */ + private void migrateReceptionStatusesToCodes() { + List 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(); } } diff --git a/backend/src/main/java/com/bizcare/agent/DomainEntities.java b/backend/src/main/java/com/bizcare/agent/DomainEntities.java index 271c70f..03438a0 100644 --- a/backend/src/main/java/com/bizcare/agent/DomainEntities.java +++ b/backend/src/main/java/com/bizcare/agent/DomainEntities.java @@ -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; +} diff --git a/backend/src/main/java/com/bizcare/agent/ReceptionStatuses.java b/backend/src/main/java/com/bizcare/agent/ReceptionStatuses.java new file mode 100644 index 0000000..4883607 --- /dev/null +++ b/backend/src/main/java/com/bizcare/agent/ReceptionStatuses.java @@ -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 PRE_CALC_STATUSES = + List.of("f19", "f20", "f21", "f22", "f28", "f23"); + public static final List CENTER_PRE_CALC_STATUSES = + List.of("h01", "h05", "h06", "h07", "h08", "h11"); + + public static final Map 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 GIFT = map( + "g01", "지급요청", "g02", "지급재요청", "g03", "반송", "g04", "처리중", + "g05", "지급보류", "g06", "지급취소", "g07", "지급완료", "g08", "회수요청", + "g09", "회수(반환)완료", "g10", "등록완료", "g11", "삭제완료", "g12", "입금증요청", + "g13", "입금증완료", "g14", "입금증첨부완료", "g15", "입금증(센터)전달완료"); + + public static final Map CENTER = map( + "h01", "정산대기", "h02", "정산완료", "h03", "입금완료", "h04", "정산보류", + "h05", "위면해지", "h06", "직권해지", "h07", "유보중단환수", "h08", "해지", + "h09", "해지정산완료", "h10", "해지송금완료", "h11", "VOC(귀책)송금", "h12", "VOC(귀책)송금완료"); + + public static final Map GIFT_PLACE = map( + "b02", "자체발송", "b01", "본사발송", "b03", "본사(센터)", "b04", "본사(관리자)"); + + public static final Map COST_TYPE = map( + "i01", "정산", "i02", "위면해지", "i03", "직권해지", "i04", "유보중단환수", "i05", "해지", "i06", "voc차감"); + + public static final Map CENTER_COST_TYPE = map( + "k01", "정산", "k02", "위면해지", "k03", "직권해지", "k04", "유보중단환수", "k05", "해지", "k06", "voc차감"); + + /** 구 BillCare 한글 라벨 → 코드 */ + public static final Map 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 LEGACY_GIFT = Map.of( + "미지급", "", + "지급대기", "g01", + "지급요청", "g01", + "지급완료", "g07", + "반송", "g03" + ); + + public static final Map LEGACY_CENTER = Map.of( + "정산대기", "h01", + "정산완료", "h02", + "입금완료", "h03", + "회수대기", "h05", + "회수완료", "h09", + "미지정", "h01" + ); + + public static List progressCodes() { + return new ArrayList<>(PROGRESS.keySet()); + } + + public static List giftCodes() { + return new ArrayList<>(GIFT.keySet()); + } + + public static List 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 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 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 map(String... kv) { + Map 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 set(String... v) { + return new LinkedHashSet<>(Arrays.asList(v)); + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 1a8623d..f916098 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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() { } /> + } /> + + } /> + } /> + } /> } /> } /> @@ -145,6 +165,14 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> } /> diff --git a/frontend/src/components/AgentShell.tsx b/frontend/src/components/AgentShell.tsx index f942ae6..a471edd 100644 --- a/frontend/src/components/AgentShell.tsx +++ b/frontend/src/components/AgentShell.tsx @@ -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>({}) @@ -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} diff --git a/frontend/src/components/DraggableModal.tsx b/frontend/src/components/DraggableModal.tsx index 746a42b..baceebd 100644 --- a/frontend/src/components/DraggableModal.tsx +++ b/frontend/src/components/DraggableModal.tsx @@ -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 ( -
e.stopPropagation()} - > -
- -
{children}
-
-
- ) -} - -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 ( - + { + if (onClose) onClose() + else AntModal.destroyAll() + }} + > {children} ) } -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 ( +
e.stopPropagation()} + > +
+ { + e.preventDefault() + e.stopPropagation() + onClose?.() + }} + onMouseDown={onTitleMouseDown} + /> +
{children}
+
+
+ ) +} + +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 = {node} + const wrapped = ( + { + closer?.current?.() + if (!closer?.current) AntModal.destroyAll() + }} + > + {node} + + ) 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) { + 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 diff --git a/frontend/src/components/ReceptionBulkModal.tsx b/frontend/src/components/ReceptionBulkModal.tsx index 1d86a85..afc0239 100644 --- a/frontend/src/components/ReceptionBulkModal.tsx +++ b/frontend/src/components/ReceptionBulkModal.tsx @@ -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 }} /> 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])), + )} /> diff --git a/frontend/src/components/ReceptionProgressModal.tsx b/frontend/src/components/ReceptionProgressModal.tsx index 728e7a0..4bbf7d7 100644 --- a/frontend/src/components/ReceptionProgressModal.tsx +++ b/frontend/src/components/ReceptionProgressModal.tsx @@ -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 + const giftLabels = (data?.giftStatusLabels || data?.giftStatusOptions || GIFT_STATUS) as Record + 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({ 현재진행상황 - + @@ -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 }))} /> diff --git a/frontend/src/pages/care/BookingPage.tsx b/frontend/src/pages/care/BookingPage.tsx index ac44df4..a516250 100644 --- a/frontend/src/pages/care/BookingPage.tsx +++ b/frontend/src/pages/care/BookingPage.tsx @@ -572,7 +572,7 @@ export default function BookingPage() {
* 고객명, 연락처는 고객관리에도 동시등록됩니다.
diff --git a/frontend/src/pages/care/BranchFeeFlowModals.tsx b/frontend/src/pages/care/BranchFeeFlowModals.tsx new file mode 100644 index 0000000..2a0e516 --- /dev/null +++ b/frontend/src/pages/care/BranchFeeFlowModals.tsx @@ -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 = { + 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(null) + const [detailOpen, setDetailOpen] = useState(false) + const [createOpen, setCreateOpen] = useState(false) + const [detailType, setDetailType] = useState<'alone' | 'group'>('alone') + const [detailFeeId, setDetailFeeId] = useState(null) + const [editable, setEditable] = useState(false) + const [memo, setMemo] = useState('') + const [startDate, setStartDate] = useState('') + const [products, setProducts] = useState([]) + const [selectedProductKey, setSelectedProductKey] = useState(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>( + `${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>( + `${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>( + `${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) => { + 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 ( + <> + + + + + )} + > + `${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) => ( + + ), + }, + { + title: '수정', + width: 90, + align: 'center', + render: (_, r) => (r.type === 'alone' + ? ( + + ) + : '-'), + }, + { + title: '삭제', + width: 80, + align: 'center', + render: (_, r) => (r.type === 'alone' + ? ( + + ) + : '-'), + }, + ]} + /> + + + + + {editable && ( + + )} + + )} + > + {detailLoading || !detail ? ( + 불러오는 중... + ) : ( +
+ + + + {detail.fee.source || detail.fee.id} + + + {detail.fee.carrier || detail.fee.ispName || '-'} + + + {editable ? ( + setStartDate(d ? d.format('YYYY-MM-DD HH:mm:ss') : '')} + style={{ width: '100%' }} + /> + ) : (detail.fee.startDate || '-')} + + + + + +
`${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 ? ( + { + setProducts((prev) => prev.map((p, i) => (i === idx ? { ...p, fee: Number(v) || 0 } : p))) + }} + /> + ) : won(fee)), + }, + ]} + /> + + + + + + {editable ? ( + setMemo(e.target.value)} /> + ) : (memo || '')} + + {[1, 2, 3, 4, 5].map((n) => ( + + {files[n - 1] || ''} + + ))} + + + + )} + + + 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' } }} + > +
createMut.mutate(v)} + initialValues={{ + carrier: 'KT', + startDate: dayjs().startOf('day'), + }} + > + + + + +
({ ...f, key: f.key }))} + columns={[ + { + title: '상품유형', + width: 120, + render: (_, field) => ( + + + + ), + }, + { + title: '적용수수료', + width: 130, + render: (_, field) => ( + + + + ), + }, + ]} + /> + )} + + + + + + ) +} diff --git a/frontend/src/pages/care/BranchListPage.tsx b/frontend/src/pages/care/BranchListPage.tsx new file mode 100644 index 0000000..c772ed7 --- /dev/null +++ b/frontend/src/pages/care/BranchListPage.tsx @@ -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 센터 + if (l === 9) return 관리자 + if (l === 10) return 최고관리자 + return 영업점 +} + +function EditBranchFormBody({ + groups, + centers, +}: { + groups: BranchGroup[] + centers: { id: number; name: string }[] +}) { + const isPersonal = Form.useWatch('isPersonal') + const level = Form.useWatch('level') + + return ( + <> + + + + + + + + + + + + + + + + {level === 5 ? ( + + + + + + + + + + +
+ + + + + + +
+
+ + + + + + + +
+ + +
+
+ + + + + + + + + + {isPersonal === false && ( + <> + + + + +
+ + +
+
+ + )} +
+
+ + + + + + + + +
+ + + 정보동일 + +
+
+ + + + + + + + + + {(fields) => ( + + {fields.map((field) => ( + + + + ))} + + )} + + +
+
+ + + + + + + + + + + + + ) +} + +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(null) + const [editingId, setEditingId] = useState(null) + const [selectedId, setSelectedId] = useState(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>(`${apiPrefix()}/homes/branches`) + return unwrap(data) || [] + }, + }) + + const { data: groups = [] } = useQuery({ + queryKey: ['homes-branch-groups'], + queryFn: async () => { + const { data } = await client.get>(`${apiPrefix()}/homes/branch-groups`) + return unwrap(data) || [] + }, + }) + + const { data: centers = [] } = useQuery({ + queryKey: ['homes-branch-centers'], + queryFn: async () => { + const { data } = await client.get>(`${apiPrefix()}/homes/branch-centers`) + return unwrap(data) || [] + }, + }) + + const sidoOptions = useMemo(() => { + const set = new Set() + 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) => { + 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) => { + 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>( + `${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: ( +
+

{row.officeName} 영업점을 삭제하시겠습니까?

+

관련 접수·정산 자료는 삭제되지 않지만, 영업점 계정 정보는 제거됩니다.

+
+ ), + okText: '삭제', + okButtonProps: { danger: true }, + cancelText: '취소', + onOk: () => deleteMut.mutateAsync(row.id), + }) + } + + const columns: ColumnsType = [ + { 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) => ( + + ), + }, + { + title: '승인여부', + width: 80, + align: 'center', + render: (_, r) => ( + r.grant === 1 + ? ( + + ) + : ( + + ) + ), + }, + { + title: '수정', + width: 70, + align: 'center', + render: (_, r) => ( + + ), + }, + { + title: '삭제', + width: 70, + align: 'center', + render: (_, r) => ( + + ), + }, + ] + + return ( +
+
+
+

+ + 영업점 목록 +

+

영업점을 검색·등록하고 승인·수수료·상세정보를 관리합니다.

+
+ +
+ + + + setDraft((d) => ({ ...d, fSido: v ?? '' }))} + options={sidoOptions.map((s) => ({ value: s, label: s }))} + /> + setDraft((d) => ({ ...d, fUserid: e.target.value }))} + onPressEnter={() => { setApplied(draft); setPage(0) }} + /> + setDraft((d) => ({ ...d, fOffice: e.target.value }))} + onPressEnter={() => { setApplied(draft); setPage(0) }} + /> + setDraft((d) => ({ ...d, fName: e.target.value }))} + onPressEnter={() => { setApplied(draft); setPage(0) }} + /> + + 총 {filtered.length}건 + + +
setPage(p - 1), + }} + onRow={(r) => ({ + onClick: () => setSelectedId(r.id), + className: selectedId === r.id ? 'row-selected' : undefined, + })} + scroll={{ x: 1400 }} + locale={{ emptyText: '영업점이 없습니다.' }} + /> + + + 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' } }} + > +
createMut.mutate(v)} + initialValues={{ level: 1, isTax: 1 }} + > + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + ({ value: g.id, label: g.name }))} /> + + + + +
({ + 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) => ( + `${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) => {won(r.total)} }, + { + title: '작업', + width: 150, + align: 'center' as const, + render: (_: unknown, r: Line) => ( + + + + + ), + }, + { + title: '수수료현황', + width: 100, + align: 'center' as const, + fixed: 'right' as const, + render: (_: unknown, r: Line) => ( + + ), + }, + ]} + scroll={{ x: 1200 }} + /> + + 기타정산 setPlus(Number(v || 0))} /> + 기타차감 setMinus(Number(v || 0))} /> + 정산일자 setCompleted(d?.format('YYYY-MM-DD') || today())} /> + 메모 setMemo(e.target.value)} style={{ width: 180 }} /> + +
+ { setMergeMode(e.target.value); setMergeGroupId(undefined) }}> + 새 그룹으로 정산완료 + 기존 그룹에 결합 + + {mergeMode === 'merge' && ( +
({ 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) => ( + + ), + }, + ]} + scroll={{ x: 1100 }} + /> + setOpen(null)} + {...fullscreenDrawerProps} + title={open ? ( + setOpen(null)} + /> + ) : null} + extra={open ? setOpen(null)} /> : null} + > + {open && ( + { setOpen(null); refetch() }} + /> + )} + + + ) +} + +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[]>>(`${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 ( +
+
({ + 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) => {won(v)} }, + { + title: '수수료현황', + width: 100, + align: 'center' as const, + fixed: 'right' as const, + render: (_: unknown, r: Line) => ( + + ), + }, + ]} + scroll={{ x: 1100 }} + /> + + + {isTransferredEdit ? (kind === 'branch' ? '송금완료 수정' : '입금완료 수정') : '정산완료 수정'} + 기타정산 setPlus(Number(v || 0))} /> + 기타차감 { + const n = Number(v || 0) + setMinus(n > 0 ? -Math.abs(n) : n) + }} /> + 정산일자 setCompleted(d?.format('YYYY-MM-DD') || today())} /> + {isTransferredEdit && ( + {kind === 'branch' ? '송금일' : '입금일'}{' '} + setTransferred(d?.format('YYYY-MM-DD') || today())} /> + + )} + 메모 setMemo(e.target.value)} style={{ width: 160 }} /> + {kind === 'branch' && isTransferredEdit && ( + <> + 은행 setBank(e.target.value)} style={{ width: 80 }} /> + 계좌 setAccountNumber(e.target.value)} style={{ width: 120 }} /> + 예금주 setAccountName(e.target.value)} style={{ width: 80 }} /> + + )} + + + + {!isTransferredEdit && ( + + { + setTaxBill(e.target.checked) + setRatePercent(e.target.checked ? 10 : -3.3) + }}>세금계산서 발행 + 세율(%) setRatePercent(Number(v || 0))} /> + {taxBill && ( + 발행일 setTaxDate(d?.format('YYYY-MM-DD') || today())} /> + )} + {kind === 'branch' ? '송금일' : '입금일'}{' '} + setTransferred(d?.format('YYYY-MM-DD') || today())} /> + + 정산합계 {won(group.total)} / 세액 {won(tax)} / 지급액 {won(payout)} + + + + )} + + {isTransferredEdit && ( + + { + setTaxBill(e.target.checked) + setRatePercent(e.target.checked ? 10 : -3.3) + }}>세금계산서 발행 + 세율(%) setRatePercent(Number(v || 0))} /> + {taxBill && ( + 발행일 setTaxDate(d?.format('YYYY-MM-DD') || today())} /> + )} + 정산합계 {won(group.total)} / 세액 {won(tax)} / 지급액 {won(payout)} + + )} + + ) +} + +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) => ( + + ), + } + + 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) => ( + {(r.plusCnt ?? 0).toLocaleString('ko-KR')} / {(r.minusCnt ?? 0).toLocaleString('ko-KR')} + ), + }, + 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) => ( + {(r.plusCnt ?? 0).toLocaleString('ko-KR')} / {(r.minusCnt ?? 0).toLocaleString('ko-KR')} + ), + }, + detailCol, + ] + + return ( +
+ + {kind === 'branch' && ( + <> + setDraft((p) => ({ ...p, title: e.target.value }))} style={{ width: 140 }} /> + setDraft((p) => ({ ...p, sub: e.target.value }))} style={{ width: 100 }} /> + setDraft((p) => ({ ...p, contact: e.target.value }))} style={{ width: 120 }} /> + setDraft((p) => ({ ...p, tax: v || '' }))} + options={[ + { value: '세금계산서발행', label: '세금계산서발행' }, + { value: '종합소득세공제', label: '종합소득세공제' }, + ]} /> + + )} + {kind === 'center' && ( + <> + setDraft((p) => ({ ...p, title: e.target.value }))} style={{ width: 160 }} /> + setDraft((p) => ({ ...p, sub: e.target.value }))} style={{ width: 100 }} /> + setDraft((p) => ({ ...p, contact: e.target.value }))} style={{ width: 120 }} /> + + )} + + +

행 클릭 또는 상세보기 버튼으로 상세리스트가 열립니다.

+
({ onClick: () => onOpen(r), style: { cursor: 'pointer' } })} + columns={kind === 'branch' ? branchCols : centerCols} + scroll={{ x: kind === 'branch' ? 1100 : 700 }} + /> + + ) +} diff --git a/frontend/src/pages/care/ContractBalancePage.tsx b/frontend/src/pages/care/ContractBalancePage.tsx index 1193f23..c76b208 100644 --- a/frontend/src/pages/care/ContractBalancePage.tsx +++ b/frontend/src/pages/care/ContractBalancePage.tsx @@ -448,7 +448,7 @@ export default function ContractBalancePage() {
diff --git a/frontend/src/pages/care/ContractWritePage.tsx b/frontend/src/pages/care/ContractWritePage.tsx index bf05098..f9436bb 100644 --- a/frontend/src/pages/care/ContractWritePage.tsx +++ b/frontend/src/pages/care/ContractWritePage.tsx @@ -424,7 +424,7 @@ export default function ContractWritePage() {
- +
diff --git a/frontend/src/pages/care/CustomerPage.tsx b/frontend/src/pages/care/CustomerPage.tsx index b2ec65a..168dfde 100644 --- a/frontend/src/pages/care/CustomerPage.tsx +++ b/frontend/src/pages/care/CustomerPage.tsx @@ -660,7 +660,7 @@ export default function CustomerPage() {
diff --git a/frontend/src/pages/care/DeliveryPage.tsx b/frontend/src/pages/care/DeliveryPage.tsx index 79ffc85..4a5790f 100644 --- a/frontend/src/pages/care/DeliveryPage.tsx +++ b/frontend/src/pages/care/DeliveryPage.tsx @@ -276,7 +276,7 @@ export default function DeliveryPage() {
diff --git a/frontend/src/pages/care/FeeOrderPopup.tsx b/frontend/src/pages/care/FeeOrderPopup.tsx new file mode 100644 index 0000000..86bef45 --- /dev/null +++ b/frontend/src/pages/care/FeeOrderPopup.tsx @@ -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 = { + 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( + + {node} + , + ) +} + +/** + * 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>( + `${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( + + + + )} + > +
{content}
+
, + ) +} + +function showFeeStatusModal(view: FeeOrderView, kind: 'branch' | 'center') { + const entityLabel = kind === 'center' ? '입력처' : '영업점' + const close = () => unmountFeePopup() + + mountFeePopup( + + + + )} + > +
+
+ + + + + +
{entityLabel}{view.entityName}
통신사{view.ispName}
적용날짜{view.startDate}
+ +
상품별 수수료
+ `${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 ? ( +
+ + + +
메모{view.memo}
+ ) : null} + +
, + ) +} + +/** 행 클릭 시 입력/체크 영역은 무시 */ +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 : '수수료 조회 실패') +} diff --git a/frontend/src/pages/care/InCompanyPage.tsx b/frontend/src/pages/care/InCompanyPage.tsx index 8e1fe54..926d2d2 100644 --- a/frontend/src/pages/care/InCompanyPage.tsx +++ b/frontend/src/pages/care/InCompanyPage.tsx @@ -331,7 +331,7 @@ export default function InCompanyPage() {
diff --git a/frontend/src/pages/care/InternalBoardPage.tsx b/frontend/src/pages/care/InternalBoardPage.tsx index 350019c..681b3bc 100644 --- a/frontend/src/pages/care/InternalBoardPage.tsx +++ b/frontend/src/pages/care/InternalBoardPage.tsx @@ -252,7 +252,7 @@ export default function InternalBoardPage({ code, title }: Props) {
diff --git a/frontend/src/pages/care/OpenBalancePage.tsx b/frontend/src/pages/care/OpenBalancePage.tsx index 697a380..d37e3a8 100644 --- a/frontend/src/pages/care/OpenBalancePage.tsx +++ b/frontend/src/pages/care/OpenBalancePage.tsx @@ -345,7 +345,7 @@ export default function OpenBalancePage() {
diff --git a/frontend/src/pages/care/OpenUnitCostPage.tsx b/frontend/src/pages/care/OpenUnitCostPage.tsx index cd8aa18..e5bd1bf 100644 --- a/frontend/src/pages/care/OpenUnitCostPage.tsx +++ b/frontend/src/pages/care/OpenUnitCostPage.tsx @@ -228,7 +228,7 @@ export default function OpenUnitCostPage() {
- +
diff --git a/frontend/src/pages/care/OutCompanyPage.tsx b/frontend/src/pages/care/OutCompanyPage.tsx index 5c53f24..d0b4b20 100644 --- a/frontend/src/pages/care/OutCompanyPage.tsx +++ b/frontend/src/pages/care/OutCompanyPage.tsx @@ -418,7 +418,7 @@ export default function OutCompanyPage() {
diff --git a/frontend/src/pages/care/PendingCalendarPage.tsx b/frontend/src/pages/care/PendingCalendarPage.tsx index c384745..0c61647 100644 --- a/frontend/src/pages/care/PendingCalendarPage.tsx +++ b/frontend/src/pages/care/PendingCalendarPage.tsx @@ -414,7 +414,7 @@ export default function PendingCalendarPage() {
diff --git a/frontend/src/pages/care/PendingPage.tsx b/frontend/src/pages/care/PendingPage.tsx index b1d070b..7199d2e 100644 --- a/frontend/src/pages/care/PendingPage.tsx +++ b/frontend/src/pages/care/PendingPage.tsx @@ -495,7 +495,7 @@ export default function PendingPage() {
diff --git a/frontend/src/pages/care/PreparedDetailEditor.tsx b/frontend/src/pages/care/PreparedDetailEditor.tsx new file mode 100644 index 0000000..b187a42 --- /dev/null +++ b/frontend/src/pages/care/PreparedDetailEditor.tsx @@ -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 = { + 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 + +const FEE_LABELS: Record = { + 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(defaultFilters) + const [applied, setApplied] = useState(defaultFilters) + const [page, setPage] = useState(0) + const [page2, setPage2] = useState(0) + const [selected, setSelected] = useState>(new Set()) + const [edits, setEdits] = useState>({}) + + const applyFilter = (patch: Partial) => { + 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>(`${base}/prepared/detail`, { params }) + return unwrap(data) + }, + }) + + const { data: suggestions } = useQuery({ + queryKey: ['calc', kind, 'suggest', row.entityKey], + queryFn: async () => { + const { data } = await client.get>(`${base}/suggest`, { + params: { entity: row.entityKey }, + }) + return unwrap(data) || [] + }, + }) + + const suggestById = useMemo(() => { + const m = new Map() + 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() + 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: ( + { + 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) => ( + toggle(r.orderId)} /> + ), + }, + { + title: '번호', + width: 70, + render: (_: unknown, r: PreparedRow, idx: number) => ( +
+
{r.orderId.toLocaleString()}
+
({idx + 1 + page * applied.perPage})
+
+ ), + }, + { + title: , + width: 100, + render: (_: unknown, r: PreparedRow) => r.orderDate.replace(' ', '\n'), + }, + { + title: , + width: 100, + dataIndex: 'opened', + }, + { + title: , + width: 90, + dataIndex: 'centerName', + ellipsis: true, + }, + { + title: , + width: 80, + dataIndex: 'ispName', + }, + { + title: , + width: 80, + render: (_: unknown, r: PreparedRow) => ptLabel(r.productType), + }, + { + title: , + width: 120, + dataIndex: 'productName', + ellipsis: true, + }, + { title: '이벤트', width: 80, dataIndex: 'eventNames', ellipsis: true }, + { + title: , + width: 80, + dataIndex: 'agreementName', + }, + { + title: , + width: 60, + dataIndex: 'sido', + }, + { + title: , + width: 90, + render: (_: unknown, r: PreparedRow) => ( +
+
{r.customerName}
+ {r.ssidUseCount != null && r.ssidUseCount > 1 &&
({r.ssidUseCount})
} +
+ ), + }, + { + title: , + width: 110, + dataIndex: 'contractNumber', + }, + { + title: '추가메모', + width: 90, + render: (_: unknown, r: PreparedRow) => { + const e = edits[r.orderId] ?? initEdit(r) + return 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 ( + 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 {won(totalOf(e))} + }, + }, + { + title: '취소', + width: 70, + fixed: 'right' as const, + align: 'center' as const, + render: (_: unknown, r: PreparedRow) => ( + + ), + }, + { + title: '수수료현황', + width: 100, + fixed: 'right' as const, + align: 'center' as const, + render: (_: unknown, r: PreparedRow) => ( + + ), + }, + ] + + const minusColumns = [ + { + title: ( + { + 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) => ( + toggle(r.orderId)} /> + ), + }, + { + title: '번호', + width: 70, + render: (_: unknown, r: PreparedRow, idx: number) => ( +
+
{r.orderId.toLocaleString()}
+
({idx + 1 + page2 * applied.perPage})
+
+ ), + }, + { 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) => ( +
{r.ispName}
{r.productName}
+ ), + }, + { + title: '고객/약정', + width: 110, + render: (_: unknown, r: PreparedRow) => ( +
+
{r.customerName}
+
{r.agreementName}
+
+ ), + }, + { + title: '환수구분/계약번호', + width: 130, + render: (_: unknown, r: PreparedRow) => ( +
+
{r.minusCategory || progressLabel(r.status)}
+
{r.contractNumber}
+
+ ), + }, + { + title: '사은품', + width: 100, + render: (_: unknown, r: PreparedRow) => ( +
+
{r.giftType}
+
{GIFT_PLACE[r.giftPlace] || r.giftPlace}
+
+ ), + }, + { + title: '내역', + width: 110, + render: (_: unknown, r: PreparedRow) => ( +
+
{r.progressMemo}
+
{GIFT_STATUS[r.giftStatus] || r.giftStatus}
+
+ ), + }, + { + title: '추가메모', + width: 90, + render: (_: unknown, r: PreparedRow) => { + const e = edits[r.orderId] ?? initEdit(r) + return patch(r.orderId, 'memo', ev.target.value)} /> + }, + }, + { + title: '구분', + width: 70, + render: () =>
이전정산
환수금액
, + }, + ...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 = { + cost: r.prevCost, event: r.prevEvent, agreement: r.prevAgreement, local: r.prevLocal, + gift: r.prevGift, voc: r.prevVoc, etc: r.prevEtc, + } + return ( +
+ + 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 ( +
+
+ +
+
{won(totalOf(e))}
+
+ ) + }, + }, + { + title: '수수료현황', + width: 100, + fixed: 'right' as const, + align: 'center' as const, + render: (_: unknown, r: PreparedRow) => ( + + ), + }, + ] + + return ( +
+

{row.title} — 가정산 상세페이지

+ + {data?.center && ( + + + + + + + + +
입력처명{data.center.name}담당자{data.center.manager}연락처{data.center.phone}
+ )} + + {data?.branch && ( + + + + + + + + + +
영업점명{data.branch.officeName}이름{data.branch.name}휴대전화{data.branch.phone}일반전화{data.branch.tel}
+ )} + + + setFilters((f) => ({ ...f, dateType: e.target.value }))} + optionType="button" + options={[ + { value: 'order_date', label: '접수일' }, + { value: 'opended', label: '개통일' }, + ]} + /> + setFilters((f) => ({ ...f, orderDateStart: d?.format('YYYY-MM-DD') || '2010-01-01' }))} /> + ~ + setFilters((f) => ({ ...f, orderDateEnd: d?.format('YYYY-MM-DD') || today() }))} /> + {quickRanges().map((q) => ( + + ))} + 한 페이지당 setFilters((f) => ({ ...f, perPage: Math.max(10, Number(v || 50)) }))} style={{ width: 64 }} /> + + + + + + applyFilter({ productType: v })} + options={Object.entries(PRODUCT_TYPE).map(([k, v]) => ({ value: k, label: v }))} + /> + setFilters((f) => ({ ...f, customerName: e.target.value }))} + onPressEnter={search} + style={{ width: 120 }} + /> + setFilters((f) => ({ ...f, contractNumber: e.target.value }))} + onPressEnter={search} + style={{ width: 140 }} + /> + + +

+ 정산 리스트 + {data ? ({data.plus.totalElements}) : null} +

+ 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) + }, + })} + /> + +

+ 해지 리스트 + {data ? ({data.minus.totalElements}) : null} +

+
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) + }, + })} + /> + + + + + 선택 {selected.size}건 · 행 클릭: 수수료 현황 + + + ) +} diff --git a/frontend/src/pages/care/ProductHomePage.tsx b/frontend/src/pages/care/ProductHomePage.tsx index 18db978..1766988 100644 --- a/frontend/src/pages/care/ProductHomePage.tsx +++ b/frontend/src/pages/care/ProductHomePage.tsx @@ -325,7 +325,7 @@ export default function ProductHomePage() { )}
diff --git a/frontend/src/pages/care/ProductInternetPage.tsx b/frontend/src/pages/care/ProductInternetPage.tsx index 5e0082e..99ea58b 100644 --- a/frontend/src/pages/care/ProductInternetPage.tsx +++ b/frontend/src/pages/care/ProductInternetPage.tsx @@ -325,7 +325,7 @@ export default function ProductInternetPage() { )}
diff --git a/frontend/src/pages/care/ReceptionListPage.tsx b/frontend/src/pages/care/ReceptionListPage.tsx index 8830aad..98764c7 100644 --- a/frontend/src/pages/care/ReceptionListPage.tsx +++ b/frontend/src/pages/care/ReceptionListPage.tsx @@ -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 + giftStatusLabels?: Record + settleStatusLabels?: Record carriers?: string[] branches?: string[] counselors?: string[] @@ -105,13 +101,6 @@ const SIDO_LIST = [ '경북', '대구', '경남', '울산', '부산', '제주', ] -const GIFT_PLACE_LABEL: Record = { - 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) + const giftSelectOpts = statusOptions(giftLabels as Record) + const settleSelectOpts = statusOptions(settleLabels as Record) + const setD = (patch: Partial>) => setDraft((p) => ({ ...p, ...patch })) const applyF = (patch: Partial>) => { const next = { ...draft, ...patch, page: 0 } @@ -416,8 +408,6 @@ export default function ReceptionListPage() {
- -
@@ -456,6 +446,13 @@ export default function ReceptionListPage() { onChange={(e) => setD({ size: Number(e.target.value) || 50 })} /> +
+ + +
+
+ +
검색 결과: 총 {(query.data?.total ?? 0).toLocaleString()} 건
@@ -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%' }} /> @@ -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%' }} /> @@ -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%' }} /> @@ -701,7 +698,7 @@ export default function ReceptionListPage() {

{o.employeeName || o.counselor || ''}

-

{o.status || o.progressStatus || ''}

+

{o.progressStatusLabel || progressLabel(o.progressStatus || o.status) || ''}

개통일: {o.opened || o.openDate || '-'}

해지일: {o.canceled || '-'}

@@ -717,13 +714,13 @@ export default function ReceptionListPage() {

{o.giftSended || o.giftDate || ''}

-

{o.giftStatus || ''}

+

{o.giftStatusLabel || giftLabel(o.giftStatus) || ''}

{num(giftAmt)}

- {o.centerStatus || o.settleStatus || ''} + {o.centerStatusLabel || centerLabel(o.centerStatus || o.settleStatus) || ''}
-

정산상태: {o.settleStatus || '-'}

+

입력처정산: {o.centerStatusLabel || centerLabel(o.settleStatus) || '-'}

개통일: {o.opened || o.openDate || '-'}

해지일: {o.canceled || o.cancelDate || '-'}

@@ -788,13 +785,13 @@ export default function ReceptionListPage() { - - - -
diff --git a/frontend/src/pages/care/SettingAgencyPage.tsx b/frontend/src/pages/care/SettingAgencyPage.tsx index 23bbf92..cc25fec 100644 --- a/frontend/src/pages/care/SettingAgencyPage.tsx +++ b/frontend/src/pages/care/SettingAgencyPage.tsx @@ -416,7 +416,7 @@ export default function SettingAgencyPage() { loading={saveMut.isPending} icon={} > - {editing ? '수정합니다' : '등록'} + {editing ? '수정' : '등록'} + + + ), + }, + ], + [removeMut], + ) + + return ( +
+
+
+

+ + 게시판관리 +

+

게시판 정의와 목록/읽기/쓰기 권한 레벨을 관리합니다.

+
+
+ + + +
+
게시판명
+ setForm({ ...form, name: e.target.value })} + style={{ width: 180 }} + /> +
+
+
목록 권한
+ setForm({ ...form, readLevel: v })} + options={LEVELS} + /> +
+
+
쓰기 권한
+ + + + + + + + + + + + + + + + + + + setEditTarget(null)} + onOk={() => editForm.submit()} + confirmLoading={saveMut.isPending} + destroyOnClose + width={420} + > +
saveMut.mutate(v)}> + + + + + + + + + +
+
+ + setAuthTarget(null)} + onOk={() => authForm.submit()} + confirmLoading={authMut.isPending} + destroyOnClose + width={440} + > +
authMut.mutate(v)}> + {authTarget?.mode === 'auth' && ( + + + + )} + + + + + + +
+
+
+ ) +} diff --git a/frontend/src/pages/care/SettingConfigurePage.tsx b/frontend/src/pages/care/SettingConfigurePage.tsx new file mode 100644 index 0000000..b848469 --- /dev/null +++ b/frontend/src/pages/care/SettingConfigurePage.tsx @@ -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>({}) + + const query = useQuery({ + queryKey: ['homes-configure'], + queryFn: async () => { + const { data } = await client.get>>( + `${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, + ) => ( + + {children} + {hint && ( + + {hint} + + )} + + ) + + return ( +
+
+
+

+ + 기타 환경 설정 +

+

사이트·알림·IP·기능 토글 등 기타 설정을 관리합니다.

+
+ + + +
+ + +
+ {field( + '사이트명', + set('sitename', e.target.value)} + style={{ maxWidth: 320 }} + />, + )} + {field( + '고객센터번호', + set('tel', e.target.value)} + style={{ maxWidth: 220 }} + />, + '로그인 페이지에 표시될 고객센터 번호입니다.', + )} + {field( + 'FAX', + set('fax', e.target.value)} + style={{ maxWidth: 220 }} + />, + )} + {field( + 'SMS 발신번호', + set('sms_callback', e.target.value)} + style={{ maxWidth: 400 }} + />, + 'SMS 발신번호는 등록 후 사용 가능합니다.', + )} + {field( + '엑셀 다운로드 이메일', + set('excel_down_email', e.target.value)} + style={{ maxWidth: 280 }} + />, + '설정 시 엑셀 생성 후 해당 주소로 파일이 발송됩니다.', + )} + {field( + '로고 경로', + set('logo_path', e.target.value)} + placeholder="/path/to/logo.png" + style={{ maxWidth: 400 }} + />, + '파일 업로드는 지원하지 않습니다. 로고 경로를 텍스트로 설정하세요.', + )} + {field( + '회원가입 알림', + + set('signup_sms_destno', e.target.value)} + placeholder="전화번호 (콤마 구분)" + style={{ maxWidth: 360 }} + /> + set('signup_sms_message', e.target.value)} + placeholder="발송 메시지 (@영업점 치환)" + style={{ maxWidth: 480 }} + /> + , + )} + {field( + '접수등록 알림', + + set('order_created_sms_destno', e.target.value)} + placeholder="전화번호 (콤마 구분)" + style={{ maxWidth: 360 }} + /> + set('order_created_sms_message', e.target.value)} + placeholder="발송 메시지 (@영업점 치환)" + style={{ maxWidth: 480 }} + /> + , + )} + {field( + '승인 알림', + + setChk('branch_granted_sms', e.target.checked)} + > + 승인 메시지를 발송합니다. + + set('branch_granted_message', e.target.value)} + style={{ maxWidth: 480 }} + /> + , + )} + {field( + '접속 허용 IP', + set('ip_list', e.target.value)} + placeholder="공백 또는 콤마로 구분" + />, + '관리자 접속 시에만 적용됩니다. 비우면 제한 없음.', + )} + {field( + 'IP 정책 무시 아이디', + set('enable_ip_userid', e.target.value)} + placeholder="공백 또는 콤마로 구분" + />, + )} + {field( + '회원가입 약관', + set('agreement', e.target.value)} + />, + )} + {field( + '로그인 소스 변경', + + setChk('login_ui_apply', e.target.checked)} + > + 로그인 소스를 적용합니다. + + set('login_ui', e.target.value)} + /> + , + )} + +
+ + +
+ {field( + '회사키(company_key)', + set('company_key', e.target.value)} + />, + )} + {field( + '관제 서버 URL(manager_url)', + set('manager_url', e.target.value)} + />, + )} + +
+
+ + + {TOGGLES.map(([key, label]) => ( +
+ {label} + +
+ ))} +
+
+ ) +} diff --git a/frontend/src/pages/care/SettingFeePage.tsx b/frontend/src/pages/care/SettingFeePage.tsx new file mode 100644 index 0000000..cb7a878 --- /dev/null +++ b/frontend/src/pages/care/SettingFeePage.tsx @@ -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 = { + e01: '인터넷', + e02: '전화', + e03: 'TV', + e04: '렌탈', +} + +async function loadTargets(tab: TabKey): Promise { + try { + const { data } = await client.get>( + `${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>( + `${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>( + `${apiPrefix()}/homes/branch-groups`, + ) + return (unwrap(data) || []).map((r) => ({ id: r.id, name: r.name || `#${r.id}` })) + } + const { data } = await client.get>( + `${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( + ['center', 'group', 'branch'].includes(initTab) ? initTab : 'center', + ) + + return ( +
+
+
+

+ + 수수료관리 +

+

입력처·그룹·영업점 수수료 시트를 관리합니다.

+
+
+ + + setTab(k as TabKey)} + items={[ + { + key: 'center', + label: '센터 수수료', + children: ( + + ), + }, + { + key: 'group', + label: '그룹 수수료', + children: ( + + ), + }, + { + key: 'branch', + label: '개별(영업점) 수수료', + children: ( + + ), + }, + ]} + /> + +
+ ) +} + +function FeeSection({ + tab, + initialTargetId = null, +}: { + tab: TabKey + initialTargetId?: number | null +}) { + const qc = useQueryClient() + const [targetId, setTargetId] = useState(initialTargetId) + const [createOpen, setCreateOpen] = useState(false) + const [detailOpen, setDetailOpen] = useState(false) + const [detailFeeId, setDetailFeeId] = useState(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([]) + 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>(listUrl(tab, targetId!)) + return unwrap(data) || [] + }, + }) + + const ispQuery = useQuery({ + queryKey: ['homes-isp'], + queryFn: async () => { + const { data } = await client.get>(`${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>( + `${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) => { + 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 ( +
+ + ({ + value: i.id, + label: i.name || i.telecom || `#${i.id}`, + }))} + /> + + + + + + + + + {(fields, { add, remove }) => ( + <> +
+ 상품별 수수료 + +
+ {fields.map((field) => ( + + + + + + + + + + ))} + + )} +
+ + + + { + setDetailOpen(false) + setDetailFeeId(null) + }} + width={680} + destroyOnClose + footer={( + + + {editable && ( + + )} + + )} + > + {detailQuery.isLoading || !detailQuery.data ? ( + 불러오는 중... + ) : ( + <> + +
+ 판매처 · + {detailQuery.data.fee.carrier || detailQuery.data.fee.ispName || '-'} +
+
+ 적용일 · + {editable ? ( + setStartDate(d ? d.format('YYYY-MM-DD HH:mm:ss') : '')} + /> + ) : ( + detailQuery.data.fee.startDate || '-' + )} +
+
+ 메모 · + {editable ? ( + setMemo(e.target.value)} /> + ) : ( + detailQuery.data.fee.memo || '-' + )} +
+
+ `${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 ? ( + { + setProducts((prev) => + prev.map((p, i) => (i === idx ? { ...p, fee: Number(n) || 0 } : p)), + ) + }} + /> + ) : ( + `${(v ?? 0).toLocaleString('ko-KR')} 원` + ), + }, + ]} + /> + + )} +
+
+ ) +} diff --git a/frontend/src/pages/care/SettingIspGroupPage.tsx b/frontend/src/pages/care/SettingIspGroupPage.tsx new file mode 100644 index 0000000..b683bc9 --- /dev/null +++ b/frontend/src/pages/care/SettingIspGroupPage.tsx @@ -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(null) + const [editName, setEditName] = useState('') + + const query = useQuery({ + queryKey: ['homes-isp-group'], + enabled: open, + queryFn: async () => { + const { data } = await client.get>(`${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 ( + + + setName(e.target.value)} + onPressEnter={() => { if (name.trim()) addMut.mutate() }} + style={{ width: 260 }} + /> + + + + v ?? '-', + }, + { + title: '그룹명', + dataIndex: 'name', + render: (_: unknown, row: GroupRow) => + editId === row.id ? ( + setEditName(e.target.value)} + onPressEnter={() => saveMut.mutate()} + /> + ) : ( + row.name + ), + }, + { + title: '', + width: 140, + render: (_: unknown, row: GroupRow) => + editId === row.id ? ( + + + + + ) : ( + + + + + ), + }, + ]} + /> + + ) +} diff --git a/frontend/src/pages/care/SettingIspLinkPage.tsx b/frontend/src/pages/care/SettingIspLinkPage.tsx new file mode 100644 index 0000000..8cc9fcb --- /dev/null +++ b/frontend/src/pages/care/SettingIspLinkPage.tsx @@ -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>(`${apiPrefix()}/homes/isp-link`) + return unwrap(data) || [] + }, + }) + + const ispQuery = useQuery({ + queryKey: ['homes-isp'], + queryFn: async () => { + const { data } = await client.get>(`${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) => ( + + {row.seq ?? '-'} + + + + ), + }, + { + 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 ? ( + + {v} + + ) : ( + '' + ), + }, + { + title: '상태', + width: 80, + align: 'center' as const, + render: (_: unknown, row: IspLink) => ( + + ), + }, + { + title: '', + width: 140, + render: (_: unknown, row: IspLink) => ( + + + + + ), + }, + ], + [ispQuery.data, moveMut, onoffMut, removeMut], + ) + + return ( +
+
+
+

+ + 통신사인증링크 +

+

통신사별 인증 링크를 등록·관리합니다.

+
+
+ + + + setForm({ ...form, name: e.target.value })} + style={{ width: 180 }} + /> + setForm({ ...form, url: e.target.value })} + style={{ minWidth: 280, flex: 1 }} + /> + + {form.id && ( + + )} + + + + + + +
+ ) +} diff --git a/frontend/src/pages/care/SettingIspPage.tsx b/frontend/src/pages/care/SettingIspPage.tsx new file mode 100644 index 0000000..e4b4d75 --- /dev/null +++ b/frontend/src/pages/care/SettingIspPage.tsx @@ -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(null) + const [productIsp, setProductIsp] = useState(null) + const [agreementIsp, setAgreementIsp] = useState(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>(`${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>( + `${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>( + `${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) => ( + + ), + }, + { + title: '상품관리', + width: 88, + align: 'center' as const, + render: (_: unknown, row: IspRow) => ( + + ), + }, + { + title: '이벤트관리', + width: 96, + align: 'center' as const, + render: () => ( + + ), + }, + { + title: '약정관리', + width: 88, + align: 'center' as const, + render: (_: unknown, row: IspRow) => ( + + ), + }, + { + title: '지사관리', + width: 80, + align: 'center' as const, + render: () => ( + + ), + }, + { + title: '출력순서', + width: 90, + align: 'center' as const, + render: (_: unknown, row: IspRow) => ( + + + + + ), + }, + { + title: 'ON/OFF', + width: 80, + align: 'center' as const, + render: (_: unknown, row: IspRow) => ( + + ), + }, + { + title: '삭제', + width: 72, + align: 'center' as const, + render: (_: unknown, row: IspRow) => ( + + ), + }, + ], + [moveMut, onoffMut, removeMut, productForm, agreementForm], + ) + + return ( +
+
+
+

+ + 통신사관리 +

+

통신사·상품·약정을 등록하고 관리합니다.

+
+ + + + +
+ + + + + + setGroupOpen(false)} /> + + { setOpen(false); setEditing(null) }} + onOk={() => form.submit()} + confirmLoading={saveMut.isPending} + okText={editing ? '수정' : '등록'} + destroyOnClose + width={420} + > +
saveMut.mutate(v)}> + + + +
+
+ + setProductIsp(null)} + footer={null} + destroyOnClose + width={560} + centered + > +
addProductMut.mutate(v)} + initialValues={{ type: 'e01' }} + > + + + + + + +
+ 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) => ( + + ), + }, + ]} + /> +
+ + setAgreementIsp(null)} + footer={null} + destroyOnClose + width={480} + centered + > +
addAgreementMut.mutate(v)} + > + + + + + + +
+ (isActive(r) ? '사용' : '중지'), + }, + { + title: '', + width: 70, + align: 'center' as const, + render: (_: unknown, r: IspAgreement) => ( + + ), + }, + ]} + /> +
+
+ ) +} diff --git a/frontend/src/pages/care/SettingLevelPage.tsx b/frontend/src/pages/care/SettingLevelPage.tsx index 372548c..b83beb9 100644 --- a/frontend/src/pages/care/SettingLevelPage.tsx +++ b/frontend/src/pages/care/SettingLevelPage.tsx @@ -18,7 +18,7 @@ type LevelsData = { const FALLBACK_ROLES = ['총괄', '팀장', '사원', '영업', '딜러', '개발자'] const FALLBACK_ROWS: Record = { - 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) diff --git a/frontend/src/pages/care/SettingMemberPage.tsx b/frontend/src/pages/care/SettingMemberPage.tsx index c5ed480..c593db1 100644 --- a/frontend/src/pages/care/SettingMemberPage.tsx +++ b/frontend/src/pages/care/SettingMemberPage.tsx @@ -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={} > - {editing ? '수정합니다' : '등록'} + {editing ? '수정' : '등록'} {editing ? ( diff --git a/frontend/src/pages/care/SettingQuickDeliveryPage.tsx b/frontend/src/pages/care/SettingQuickDeliveryPage.tsx new file mode 100644 index 0000000..096aa2b --- /dev/null +++ b/frontend/src/pages/care/SettingQuickDeliveryPage.tsx @@ -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 ( +
+
+
+

+ + 퀵전송 연동 +

+

입력처(상부점) 인증과 진행알림 로그를 관리합니다.

+
+
+ + +
+ ) +} + +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>( + `${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 ( + + + row.authorized ? ( + 인증됨 + ) : ( + 미인증 + ), + }, + { + title: '상부점 URL', + dataIndex: 'baseUrl', + ellipsis: true, + render: (v?: string | null) => v || '-', + }, + { + title: '', + width: 160, + render: (_: unknown, row: QdCenter) => ( + + + {row.authorized && ( + + )} + + ), + }, + ]} + /> + + setTarget(null)} + onOk={() => authForm.submit()} + confirmLoading={authMut.isPending} + destroyOnClose + width={440} + > +
authMut.mutate(v)}> + {target?.mode === 'auth' && ( + + + + )} + + + + + + +
+
+
+ ) +} + +function LogSection() { + const [page, setPage] = useState(0) + + const query = useQuery({ + queryKey: ['homes-qd-logs', page], + queryFn: async () => { + const { data } = await client.get>( + `${apiPrefix()}/homes/quick-delivery/logs`, + { params: { page, size: 50 } }, + ) + return unwrap(data) || { content: [], last: true } + }, + }) + + const logs = query.data?.content || [] + + return ( + + (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}` : '-'), + }, + ]} + /> + + + 페이지 {page + 1} + + + + ) +} diff --git a/frontend/src/pages/care/SettingUsageReportPage.tsx b/frontend/src/pages/care/SettingUsageReportPage.tsx new file mode 100644 index 0000000..3e55c50 --- /dev/null +++ b/frontend/src/pages/care/SettingUsageReportPage.tsx @@ -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>( + `${apiPrefix()}/homes/access-log/report`, + ) + return unwrap(data) + }, + }) + + const fmt = (n: number) => n.toLocaleString('ko-KR') + const views = query.data?.dailyPageViews || [] + + return ( +
+
+
+

+ + 이용현황 +

+

로그인·접수·페이지뷰 이용 현황을 확인합니다.

+
+
+ + {query.isLoading && 로딩 중...} + + {query.data && ( + <> + + + + + + `${fmt(v)} 건`, + }, + ]} + /> + + + )} + + {query.isError && ( + + {(query.error as Error)?.message || '이용현황을 불러오지 못했습니다.'} + + )} +
+ ) +} diff --git a/frontend/src/pages/care/SitemapPage.tsx b/frontend/src/pages/care/SitemapPage.tsx index 12ed120..9f5f708 100644 --- a/frontend/src/pages/care/SitemapPage.tsx +++ b/frontend/src/pages/care/SitemapPage.tsx @@ -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', diff --git a/frontend/src/pages/care/SmsAddressPage.tsx b/frontend/src/pages/care/SmsAddressPage.tsx index eac5de8..eb97453 100644 --- a/frontend/src/pages/care/SmsAddressPage.tsx +++ b/frontend/src/pages/care/SmsAddressPage.tsx @@ -483,7 +483,7 @@ export default function SmsAddressPage() {
diff --git a/frontend/src/pages/care/StaffManagePage.tsx b/frontend/src/pages/care/StaffManagePage.tsx index e6b7ec2..f125af9 100644 --- a/frontend/src/pages/care/StaffManagePage.tsx +++ b/frontend/src/pages/care/StaffManagePage.tsx @@ -360,7 +360,7 @@ export default function StaffManagePage() {
diff --git a/frontend/src/pages/care/receptionStatuses.ts b/frontend/src/pages/care/receptionStatuses.ts new file mode 100644 index 0000000..c87958c --- /dev/null +++ b/frontend/src/pages/care/receptionStatuses.ts @@ -0,0 +1,86 @@ +/** billing_react 호환 접수/사은품/입력처 상태 코드·라벨 */ + +export const PROGRESS_STATUS: Record = { + 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 = { + g01: '지급요청', g02: '지급재요청', g03: '반송', g04: '처리중', + g05: '지급보류', g06: '지급취소', g07: '지급완료', g08: '회수요청', + g09: '회수(반환)완료', g10: '등록완료', g11: '삭제완료', g12: '입금증요청', + g13: '입금증완료', g14: '입금증첨부완료', g15: '입금증(센터)전달완료', +} + +export const CENTER_STATUS: Record = { + h01: '정산대기', h02: '정산완료', h03: '입금완료', h04: '정산보류', + h05: '위면해지', h06: '직권해지', h07: '유보중단환수', h08: '해지', + h09: '해지정산완료', h10: '해지송금완료', h11: 'VOC(귀책)송금', h12: 'VOC(귀책)송금완료', +} + +export const GIFT_PLACE: Record = { + 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, 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' +} diff --git a/frontend/src/styles/app.css b/frontend/src/styles/app.css index de4f5f0..ae3a16d 100644 --- a/frontend/src/styles/app.css +++ b/frontend/src/styles/app.css @@ -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; +}