nation 버전 이식
This commit is contained in:
@@ -0,0 +1,683 @@
|
||||
package com.bizcare.agent;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 영업점관리 — billing_react BranchController / BranchGroupController / BranchFee entries 이식.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/agent/homes")
|
||||
@RequiredArgsConstructor
|
||||
class CareBranchController {
|
||||
private static final ZoneId KST = ZoneId.of("Asia/Seoul");
|
||||
private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private final EntityManager em;
|
||||
private final PasswordEncoder encoder;
|
||||
|
||||
// ---- 영업점 그룹 ----
|
||||
|
||||
@GetMapping("/branch-groups")
|
||||
@Transactional(readOnly = true)
|
||||
ApiResponse branchGroups(@AuthenticationPrincipal JwtUser u) {
|
||||
List<HomesBranchGroup> list = em.createQuery(
|
||||
"select g from HomesBranchGroup g where g.groupId=:gid order by g.seq asc, g.id asc",
|
||||
HomesBranchGroup.class)
|
||||
.setParameter("gid", u.getGroupId())
|
||||
.getResultList();
|
||||
return ApiResponse.ok(list.stream().map(this::groupMap).toList());
|
||||
}
|
||||
|
||||
@PostMapping("/branch-groups")
|
||||
@Transactional
|
||||
ApiResponse createBranchGroup(@RequestBody Map<String, Object> body, @AuthenticationPrincipal JwtUser u) {
|
||||
String name = str(body.get("name"));
|
||||
if (blank(name)) return ApiResponse.fail("그룹명을 입력하세요.");
|
||||
HomesBranchGroup g = new HomesBranchGroup();
|
||||
g.setGroupId(u.getGroupId());
|
||||
g.setName(name.trim());
|
||||
g.setSeq(body.get("seq") instanceof Number n ? n.intValue() : 0);
|
||||
em.persist(g);
|
||||
return ApiResponse.ok(groupMap(g));
|
||||
}
|
||||
|
||||
// ---- 영업점 목록 / CRUD ----
|
||||
|
||||
@GetMapping("/branches")
|
||||
@Transactional(readOnly = true)
|
||||
ApiResponse branches(@AuthenticationPrincipal JwtUser u) {
|
||||
List<HomesBranch> list = em.createQuery(
|
||||
"select b from HomesBranch b where b.groupId=:gid order by b.id desc",
|
||||
HomesBranch.class)
|
||||
.setParameter("gid", u.getGroupId())
|
||||
.getResultList();
|
||||
Map<Long, String> groupNames = groupNameMap(u.getGroupId());
|
||||
return ApiResponse.ok(list.stream().map(b -> listMap(b, groupNames)).toList());
|
||||
}
|
||||
|
||||
@PostMapping("/branches")
|
||||
@Transactional
|
||||
ApiResponse createBranch(@RequestBody Map<String, Object> body,
|
||||
@RequestParam(defaultValue = "1") int level,
|
||||
@RequestParam(required = false) Long branchGroupId,
|
||||
@AuthenticationPrincipal JwtUser u) {
|
||||
String userid = str(body.get("userid"));
|
||||
String password = str(body.get("password"));
|
||||
String officeName = str(body.get("officeName"));
|
||||
String name = str(body.get("name"));
|
||||
String phone = str(body.get("phone"));
|
||||
String email = str(body.get("email"));
|
||||
if (blank(userid) || blank(password)) return ApiResponse.fail("아이디와 패스워드를 입력하세요.");
|
||||
if (blank(officeName) || blank(name)) return ApiResponse.fail("영업점명과 대표자명을 입력하세요.");
|
||||
if (blank(phone) || blank(email)) return ApiResponse.fail("휴대폰과 이메일을 입력하세요.");
|
||||
if (existsUserid(u.getGroupId(), userid, null)) return ApiResponse.fail("다른사람이 사용중인 아이디입니다.");
|
||||
if (existsOfficeName(u.getGroupId(), officeName, null)) return ApiResponse.fail("다른사람이 사용중인 영업점명입니다.");
|
||||
if (existsEmail(u.getGroupId(), email, null)) return ApiResponse.fail("다른 사람이 사용중인 이메일입니다.");
|
||||
|
||||
HomesBranch b = new HomesBranch();
|
||||
b.setGroupId(u.getGroupId());
|
||||
b.setUserid(userid.trim());
|
||||
b.setPasswordHash(encoder.encode(password));
|
||||
b.setName(name.trim());
|
||||
b.setOfficeName(officeName.trim());
|
||||
b.setTel(str(body.get("tel")));
|
||||
b.setPhone(phone.trim());
|
||||
b.setFax(str(body.get("fax")));
|
||||
b.setEmail(email.trim());
|
||||
b.setZipcode(str(body.get("zipcode")));
|
||||
b.setAddress(str(body.get("address")));
|
||||
b.setAddressDetail(str(body.get("addressDetail")));
|
||||
b.setSido(deriveSido(str(body.get("zipcode")), str(body.get("sido")), str(body.get("address"))));
|
||||
b.setLevel(level);
|
||||
b.setGrantFlag(1); // 관리자 등록 시 승인
|
||||
b.setBranchGroupId(branchGroupId != null ? branchGroupId : longOrNull(body.get("branchGroupId")));
|
||||
b.setIsTax(body.get("isTax") instanceof Number n ? n.intValue() : 1);
|
||||
b.setMemo(str(body.get("memo")));
|
||||
em.persist(b);
|
||||
return ApiResponse.ok(listMap(b, groupNameMap(u.getGroupId())));
|
||||
}
|
||||
|
||||
@GetMapping("/branches/{id}/detail")
|
||||
@Transactional(readOnly = true)
|
||||
ApiResponse branchDetail(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) {
|
||||
HomesBranch b = findBranch(id, u);
|
||||
if (b == null) return ApiResponse.fail("영업점을 찾을 수 없습니다.");
|
||||
return ApiResponse.ok(detailMap(b));
|
||||
}
|
||||
|
||||
@PutMapping("/branches/{id}/detail")
|
||||
@Transactional
|
||||
ApiResponse updateBranchDetail(@PathVariable Long id, @RequestBody Map<String, Object> body,
|
||||
@AuthenticationPrincipal JwtUser u) {
|
||||
HomesBranch b = findBranch(id, u);
|
||||
if (b == null) return ApiResponse.fail("영업점을 찾을 수 없습니다.");
|
||||
|
||||
String officeName = str(body.get("officeName"));
|
||||
String email = str(body.get("email"));
|
||||
String name = str(body.get("name"));
|
||||
if (blank(officeName) || blank(name)) return ApiResponse.fail("영업점명과 이름을 입력하세요.");
|
||||
if (existsOfficeName(u.getGroupId(), officeName, id)) return ApiResponse.fail("다른사람이 사용중인 영업점명입니다.");
|
||||
if (!blank(email) && existsEmail(u.getGroupId(), email, id)) return ApiResponse.fail("다른 사람이 사용중인 이메일입니다.");
|
||||
|
||||
String password = str(body.get("password"));
|
||||
String passwordConfirmation = str(body.get("passwordConfirmation"));
|
||||
if (!blank(password)) {
|
||||
if (!password.equals(passwordConfirmation)) return ApiResponse.fail("패스워드 확인이 일치하지 않습니다.");
|
||||
b.setPasswordHash(encoder.encode(password));
|
||||
}
|
||||
|
||||
Long oldGroupId = b.getBranchGroupId();
|
||||
Long newGroupId = longOrNull(body.get("branchGroupId"));
|
||||
boolean keepFee = Boolean.TRUE.equals(body.get("keepFee"))
|
||||
|| "true".equalsIgnoreCase(String.valueOf(body.get("keepFee")));
|
||||
|
||||
b.setName(name.trim());
|
||||
b.setOfficeName(officeName.trim());
|
||||
b.setTel(str(body.get("tel")));
|
||||
b.setPhone(str(body.get("phone")));
|
||||
b.setFax(str(body.get("fax")));
|
||||
b.setEmail(email);
|
||||
b.setZipcode(str(body.get("zipcode")));
|
||||
b.setAddress(str(body.get("address")));
|
||||
b.setAddressDetail(str(body.get("addressDetail")));
|
||||
b.setSido(deriveSido(str(body.get("zipcode")), str(body.get("sido")), str(body.get("address"))));
|
||||
if (body.get("level") instanceof Number n) b.setLevel(n.intValue());
|
||||
b.setBranchGroupId(newGroupId);
|
||||
b.setCenterName(str(body.get("centerName")));
|
||||
if (blank(b.getCenterName()) && body.get("centerId") != null) {
|
||||
// centerId 호환: 숫자면 무시, 문자열이면 이름
|
||||
b.setCenterName(str(body.get("centerId")));
|
||||
}
|
||||
|
||||
boolean isPersonal = Boolean.TRUE.equals(body.get("isPersonal"))
|
||||
|| "true".equalsIgnoreCase(String.valueOf(body.get("isPersonal")));
|
||||
b.setHasBusinessLicense(!isPersonal);
|
||||
b.setLicenseNumber(isPersonal ? null : str(body.get("licenseNumber")));
|
||||
b.setLicenseType(isPersonal ? null : str(body.get("licenseType")));
|
||||
b.setLicenseItem(isPersonal ? null : str(body.get("licenseItem")));
|
||||
|
||||
b.setAccountBank(str(body.get("accountBank")));
|
||||
b.setAccountNumber(str(body.get("accountNumber")));
|
||||
boolean accountNameIsName = Boolean.TRUE.equals(body.get("accountNameIsName"))
|
||||
|| "true".equalsIgnoreCase(String.valueOf(body.get("accountNameIsName")));
|
||||
b.setAccountName(accountNameIsName ? b.getName() : str(body.get("accountName")));
|
||||
|
||||
b.setContactPhone(str(body.get("contactPhone")));
|
||||
b.setContactNateon(str(body.get("contactNateon")));
|
||||
if (body.get("acceptPhone") instanceof Number n) b.setAcceptPhone(n.intValue());
|
||||
if (body.get("acceptNateon") instanceof Number n) b.setAcceptNateon(n.intValue());
|
||||
if (body.get("isTax") instanceof Number n) b.setIsTax(n.intValue());
|
||||
b.setMemo(str(body.get("memo")));
|
||||
b.setAdminMemo(str(body.get("adminMemo")));
|
||||
b.setNotifyPhonesJson(toNotifyJson(body.get("notifyPhones")));
|
||||
|
||||
b.setPermissionOrderDateUpdate(bool(body.get("permissionOrderDateUpdate")));
|
||||
b.setPermissionCalculate(bool(body.get("permissionCalculate")));
|
||||
b.setPermissionConfig(bool(body.get("permissionConfig")));
|
||||
b.setPermissionExcelDownload(bool(body.get("permissionExcelDownload")));
|
||||
b.setPermissionChart(bool(body.get("permissionChart")));
|
||||
|
||||
// keepFee=false 이고 그룹이 바뀌면 개별 수수료는 유지, 그룹 매핑은 이름만 바뀌므로 별도 처리 없음
|
||||
if (!keepFee && !Objects.equals(oldGroupId, newGroupId) && newGroupId != null) {
|
||||
HomesBranchGroup ng = em.find(HomesBranchGroup.class, newGroupId);
|
||||
if (ng != null && Objects.equals(ng.getGroupId(), u.getGroupId())) {
|
||||
// 그룹 수수료는 branchGroupName 문자열 키 — 접수의 branchGroup 과 맞추는 용도만
|
||||
// (실제 접수 일괄 갱신은 범위 외)
|
||||
}
|
||||
}
|
||||
|
||||
return ApiResponse.ok(detailMap(b));
|
||||
}
|
||||
|
||||
@PostMapping("/branches/{id}/grant/{grant}")
|
||||
@Transactional
|
||||
ApiResponse setGrant(@PathVariable Long id, @PathVariable int grant, @AuthenticationPrincipal JwtUser u) {
|
||||
HomesBranch b = findBranch(id, u);
|
||||
if (b == null) return ApiResponse.fail("영업점을 찾을 수 없습니다.");
|
||||
if (grant != 0 && grant != 1) return ApiResponse.fail("승인값은 0 또는 1이어야 합니다.");
|
||||
b.setGrantFlag(grant);
|
||||
return ApiResponse.ok(null);
|
||||
}
|
||||
|
||||
@DeleteMapping("/branches/{id}")
|
||||
@Transactional
|
||||
ApiResponse deleteBranch(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) {
|
||||
HomesBranch b = findBranch(id, u);
|
||||
if (b == null) return ApiResponse.fail("영업점을 찾을 수 없습니다.");
|
||||
em.remove(b);
|
||||
return ApiResponse.ok(null);
|
||||
}
|
||||
|
||||
/** 영업점 수수료 목록 (개별 + 소속 그룹) — billing GET /branch-fee/branch/{id}/entries */
|
||||
@GetMapping("/branches/{id}/fees")
|
||||
@Transactional(readOnly = true)
|
||||
ApiResponse branchFees(@PathVariable Long id, @AuthenticationPrincipal JwtUser u) {
|
||||
HomesBranch b = findBranch(id, u);
|
||||
if (b == null) return ApiResponse.fail("영업점을 찾을 수 없습니다.");
|
||||
|
||||
List<Map<String, Object>> entries = new ArrayList<>();
|
||||
List<HomesBranchFee> alone = em.createQuery(
|
||||
"select f from HomesBranchFee f where f.groupId=:gid and f.feeType='alone' and f.branchName=:name",
|
||||
HomesBranchFee.class)
|
||||
.setParameter("gid", u.getGroupId())
|
||||
.setParameter("name", b.getOfficeName())
|
||||
.getResultList();
|
||||
for (HomesBranchFee f : alone) {
|
||||
entries.add(feeEntryMap(f, "alone"));
|
||||
}
|
||||
|
||||
if (b.getBranchGroupId() != null) {
|
||||
HomesBranchGroup g = em.find(HomesBranchGroup.class, b.getBranchGroupId());
|
||||
if (g != null && Objects.equals(g.getGroupId(), u.getGroupId())) {
|
||||
List<HomesBranchFee> groupFees = em.createQuery(
|
||||
"select f from HomesBranchFee f where f.groupId=:gid and f.feeType='group' and f.branchGroupName=:name",
|
||||
HomesBranchFee.class)
|
||||
.setParameter("gid", u.getGroupId())
|
||||
.setParameter("name", g.getName())
|
||||
.getResultList();
|
||||
for (HomesBranchFee f : groupFees) {
|
||||
entries.add(feeEntryMap(f, "group"));
|
||||
}
|
||||
}
|
||||
}
|
||||
// 적용일 내림차순, 동일일이면 개별(alone) 우선
|
||||
entries.sort((a, b2) -> {
|
||||
String sa = String.valueOf(a.get("startDate") == null ? "" : a.get("startDate"));
|
||||
String sb = String.valueOf(b2.get("startDate") == null ? "" : b2.get("startDate"));
|
||||
int c = sb.compareTo(sa);
|
||||
if (c != 0) return c;
|
||||
int pa = "alone".equals(a.get("type")) ? 0 : 1;
|
||||
int pb = "alone".equals(b2.get("type")) ? 0 : 1;
|
||||
return Integer.compare(pa, pb);
|
||||
});
|
||||
return ApiResponse.ok(entries);
|
||||
}
|
||||
|
||||
/** 수수료 상세 — billing branch-fee/{id}/read/{type} */
|
||||
@GetMapping("/branch-fees/{feeId}")
|
||||
@Transactional(readOnly = true)
|
||||
ApiResponse feeDetail(@PathVariable Long feeId,
|
||||
@RequestParam(defaultValue = "alone") String type,
|
||||
@AuthenticationPrincipal JwtUser u) {
|
||||
HomesBranchFee f = em.find(HomesBranchFee.class, feeId);
|
||||
if (f == null || !Objects.equals(f.getGroupId(), u.getGroupId())) {
|
||||
return ApiResponse.fail("수수료를 찾을 수 없습니다.");
|
||||
}
|
||||
String want = "group".equalsIgnoreCase(type) ? "group" : "alone";
|
||||
if (!want.equals(f.getFeeType())) {
|
||||
return ApiResponse.fail("수수료 종류가 일치하지 않습니다.");
|
||||
}
|
||||
return ApiResponse.ok(feeDetailMap(f));
|
||||
}
|
||||
|
||||
/** 개별 수수료 등록 */
|
||||
@PostMapping("/branches/{id}/fees")
|
||||
@Transactional
|
||||
ApiResponse createAloneFee(@PathVariable Long id, @RequestBody Map<String, Object> body,
|
||||
@AuthenticationPrincipal JwtUser u) {
|
||||
HomesBranch b = findBranch(id, u);
|
||||
if (b == null) return ApiResponse.fail("영업점을 찾을 수 없습니다.");
|
||||
String carrier = str(body.get("carrier"));
|
||||
if (blank(carrier)) return ApiResponse.fail("통신사를 선택하세요.");
|
||||
LocalDateTime start = parseDateTime(body.get("startDate"));
|
||||
if (start == null) return ApiResponse.fail("적용일을 입력하세요.");
|
||||
if (existsAloneFee(u.getGroupId(), b.getOfficeName(), carrier, start, null)) {
|
||||
return ApiResponse.fail("이미 등록되어 있는 적용일입니다.");
|
||||
}
|
||||
HomesBranchFee fee = new HomesBranchFee();
|
||||
fee.setGroupId(u.getGroupId());
|
||||
fee.setFeeType("alone");
|
||||
fee.setBranchName(b.getOfficeName());
|
||||
if (b.getBranchGroupId() != null) {
|
||||
HomesBranchGroup g = em.find(HomesBranchGroup.class, b.getBranchGroupId());
|
||||
if (g != null) fee.setBranchGroupName(g.getName());
|
||||
}
|
||||
fee.setCarrier(carrier);
|
||||
fee.setStartDate(start);
|
||||
fee.setMemo(str(body.get("memo")));
|
||||
em.persist(fee);
|
||||
em.flush();
|
||||
replaceProducts(fee.getId(), u.getGroupId(), body.get("products"));
|
||||
return ApiResponse.ok(feeDetailMap(fee));
|
||||
}
|
||||
|
||||
/** 개별 수수료 수정 — 그룹은 거부 */
|
||||
@PutMapping("/branch-fees/{feeId}")
|
||||
@Transactional
|
||||
ApiResponse updateAloneFee(@PathVariable Long feeId, @RequestBody Map<String, Object> body,
|
||||
@AuthenticationPrincipal JwtUser u) {
|
||||
HomesBranchFee f = em.find(HomesBranchFee.class, feeId);
|
||||
if (f == null || !Objects.equals(f.getGroupId(), u.getGroupId())) {
|
||||
return ApiResponse.fail("수수료를 찾을 수 없습니다.");
|
||||
}
|
||||
if (!"alone".equals(f.getFeeType())) {
|
||||
return ApiResponse.fail("그룹 수수료는 여기서 수정할 수 없습니다.");
|
||||
}
|
||||
LocalDateTime start = parseDateTime(body.get("startDate"));
|
||||
if (start == null) return ApiResponse.fail("적용일을 입력하세요.");
|
||||
String carrier = blank(str(body.get("carrier"))) ? f.getCarrier() : str(body.get("carrier"));
|
||||
if (existsAloneFee(u.getGroupId(), f.getBranchName(), carrier, start, feeId)) {
|
||||
return ApiResponse.fail("이미 등록되어 있는 적용일입니다.");
|
||||
}
|
||||
f.setCarrier(carrier);
|
||||
f.setStartDate(start);
|
||||
f.setMemo(str(body.get("memo")));
|
||||
if (body.containsKey("file1")) f.setFile1(str(body.get("file1")));
|
||||
if (body.containsKey("file2")) f.setFile2(str(body.get("file2")));
|
||||
if (body.containsKey("file3")) f.setFile3(str(body.get("file3")));
|
||||
if (body.containsKey("file4")) f.setFile4(str(body.get("file4")));
|
||||
if (body.containsKey("file5")) f.setFile5(str(body.get("file5")));
|
||||
replaceProducts(feeId, u.getGroupId(), body.get("products"));
|
||||
return ApiResponse.ok(feeDetailMap(f));
|
||||
}
|
||||
|
||||
@DeleteMapping("/branch-fees/{feeId}")
|
||||
@Transactional
|
||||
ApiResponse deleteAloneFee(@PathVariable Long feeId, @AuthenticationPrincipal JwtUser u) {
|
||||
HomesBranchFee f = em.find(HomesBranchFee.class, feeId);
|
||||
if (f == null || !Objects.equals(f.getGroupId(), u.getGroupId())) {
|
||||
return ApiResponse.fail("수수료를 찾을 수 없습니다.");
|
||||
}
|
||||
if (!"alone".equals(f.getFeeType())) {
|
||||
return ApiResponse.fail("그룹 수수료는 여기서 삭제할 수 없습니다.");
|
||||
}
|
||||
em.createQuery("delete from HomesBranchFeeProduct p where p.feeId=:fid and p.groupId=:gid")
|
||||
.setParameter("fid", feeId)
|
||||
.setParameter("gid", u.getGroupId())
|
||||
.executeUpdate();
|
||||
em.remove(f);
|
||||
return ApiResponse.ok(null);
|
||||
}
|
||||
|
||||
/** 등록 폼용 기본 상품 템플릿 (시드/기존 수수료 상품명 재사용) */
|
||||
@GetMapping("/branch-fee-product-templates")
|
||||
@Transactional(readOnly = true)
|
||||
ApiResponse productTemplates(@RequestParam(required = false) String carrier,
|
||||
@AuthenticationPrincipal JwtUser u) {
|
||||
List<HomesBranchFeeProduct> samples = em.createQuery(
|
||||
"select p from HomesBranchFeeProduct p where p.groupId=:gid order by p.id asc",
|
||||
HomesBranchFeeProduct.class)
|
||||
.setParameter("gid", u.getGroupId())
|
||||
.setMaxResults(40)
|
||||
.getResultList();
|
||||
LinkedHashMap<String, Map<String, Object>> uniq = new LinkedHashMap<>();
|
||||
for (HomesBranchFeeProduct p : samples) {
|
||||
String key = p.getProductType() + "|" + p.getProductName();
|
||||
if (uniq.containsKey(key)) continue;
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("productType", p.getProductType());
|
||||
m.put("productName", p.getProductName());
|
||||
m.put("fee", 0);
|
||||
uniq.put(key, m);
|
||||
}
|
||||
if (uniq.isEmpty()) {
|
||||
Object[][] defaults = {
|
||||
{"e01", "기가인터넷 1G"}, {"e01", "안심 인터넷"},
|
||||
{"e03", "B tv 프리미엄"}, {"e02", "집전화 스마트"}, {"e04", "기타"},
|
||||
};
|
||||
for (Object[] d : defaults) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("productType", d[0]);
|
||||
m.put("productName", d[1]);
|
||||
m.put("fee", 0);
|
||||
uniq.put(d[0] + "|" + d[1], m);
|
||||
}
|
||||
}
|
||||
return ApiResponse.ok(new ArrayList<>(uniq.values()));
|
||||
}
|
||||
|
||||
/** 입력처(센터) 후보 — 레벨5 영업점 + 접수 processor distinct */
|
||||
@GetMapping("/branch-centers")
|
||||
@Transactional(readOnly = true)
|
||||
ApiResponse centers(@AuthenticationPrincipal JwtUser u) {
|
||||
LinkedHashSet<String> names = new LinkedHashSet<>();
|
||||
em.createQuery(
|
||||
"select b.officeName from HomesBranch b where b.groupId=:gid and b.level=5 and b.officeName is not null",
|
||||
String.class)
|
||||
.setParameter("gid", u.getGroupId())
|
||||
.getResultList()
|
||||
.forEach(names::add);
|
||||
em.createQuery(
|
||||
"select distinct r.processor from HomesReception r where r.groupId=:gid and r.processor is not null and r.processor<>''",
|
||||
String.class)
|
||||
.setParameter("gid", u.getGroupId())
|
||||
.getResultList()
|
||||
.forEach(names::add);
|
||||
List<Map<String, Object>> out = new ArrayList<>();
|
||||
int i = 1;
|
||||
for (String n : names) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", i++);
|
||||
m.put("name", n);
|
||||
out.add(m);
|
||||
}
|
||||
return ApiResponse.ok(out);
|
||||
}
|
||||
|
||||
// ---- helpers ----
|
||||
|
||||
private void replaceProducts(Long feeId, String groupId, Object productsRaw) {
|
||||
em.createQuery("delete from HomesBranchFeeProduct p where p.feeId=:fid and p.groupId=:gid")
|
||||
.setParameter("fid", feeId)
|
||||
.setParameter("gid", groupId)
|
||||
.executeUpdate();
|
||||
if (!(productsRaw instanceof List<?> list)) return;
|
||||
for (Object o : list) {
|
||||
if (!(o instanceof Map<?, ?> m)) continue;
|
||||
String productType = str(m.get("productType"));
|
||||
String productName = str(m.get("productName"));
|
||||
if (blank(productName)) continue;
|
||||
HomesBranchFeeProduct p = new HomesBranchFeeProduct();
|
||||
p.setGroupId(groupId);
|
||||
p.setFeeId(feeId);
|
||||
p.setProductType(blank(productType) ? "e01" : productType);
|
||||
p.setProductName(productName);
|
||||
Object feeObj = m.get("fee");
|
||||
int fee = feeObj instanceof Number n ? n.intValue() : 0;
|
||||
try {
|
||||
if (!(feeObj instanceof Number) && feeObj != null && !String.valueOf(feeObj).isBlank()) {
|
||||
fee = Integer.parseInt(String.valueOf(feeObj).replace(",", "").replace("원", "").trim());
|
||||
}
|
||||
} catch (Exception ignored) { /* keep 0 */ }
|
||||
p.setFee(fee);
|
||||
em.persist(p);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean existsAloneFee(String gid, String branchName, String carrier, LocalDateTime start, Long excludeId) {
|
||||
String jpql = "select count(f) from HomesBranchFee f where f.groupId=:gid and f.feeType='alone'"
|
||||
+ " and f.branchName=:name and f.carrier=:c and f.startDate=:sd"
|
||||
+ (excludeId != null ? " and f.id<>:id" : "");
|
||||
var q = em.createQuery(jpql, Long.class)
|
||||
.setParameter("gid", gid)
|
||||
.setParameter("name", branchName)
|
||||
.setParameter("c", carrier)
|
||||
.setParameter("sd", start);
|
||||
if (excludeId != null) q.setParameter("id", excludeId);
|
||||
return q.getSingleResult() > 0;
|
||||
}
|
||||
|
||||
private Map<String, Object> feeDetailMap(HomesBranchFee f) {
|
||||
List<HomesBranchFeeProduct> products = em.createQuery(
|
||||
"select p from HomesBranchFeeProduct p where p.feeId=:fid and p.groupId=:gid order by p.productType, p.id",
|
||||
HomesBranchFeeProduct.class)
|
||||
.setParameter("fid", f.getId())
|
||||
.setParameter("gid", f.getGroupId())
|
||||
.getResultList();
|
||||
Map<String, Object> fee = new LinkedHashMap<>();
|
||||
fee.put("id", f.getId());
|
||||
fee.put("type", f.getFeeType());
|
||||
fee.put("branchName", f.getBranchName());
|
||||
fee.put("branchGroupName", f.getBranchGroupName());
|
||||
fee.put("carrier", f.getCarrier());
|
||||
fee.put("ispName", f.getCarrier());
|
||||
fee.put("startDate", f.getStartDate() == null ? null : DT.format(f.getStartDate()));
|
||||
fee.put("memo", f.getMemo());
|
||||
fee.put("file1", f.getFile1());
|
||||
fee.put("file2", f.getFile2());
|
||||
fee.put("file3", f.getFile3());
|
||||
fee.put("file4", f.getFile4());
|
||||
fee.put("file5", f.getFile5());
|
||||
fee.put("source", String.valueOf(f.getId()));
|
||||
|
||||
List<Map<String, Object>> productMaps = new ArrayList<>();
|
||||
for (HomesBranchFeeProduct p : products) {
|
||||
Map<String, Object> pm = new LinkedHashMap<>();
|
||||
pm.put("id", p.getId());
|
||||
pm.put("productType", p.getProductType());
|
||||
pm.put("productName", p.getProductName());
|
||||
pm.put("fee", p.getFee() == null ? 0 : p.getFee());
|
||||
productMaps.add(pm);
|
||||
}
|
||||
Map<String, Object> out = new LinkedHashMap<>();
|
||||
out.put("fee", fee);
|
||||
out.put("products", productMaps);
|
||||
return out;
|
||||
}
|
||||
|
||||
private static LocalDateTime parseDateTime(Object raw) {
|
||||
if (raw == null) return null;
|
||||
String s = String.valueOf(raw).trim().replace('T', ' ');
|
||||
if (s.isEmpty()) return null;
|
||||
try {
|
||||
if (s.length() == 10) return LocalDateTime.parse(s + " 00:00:00", DT);
|
||||
if (s.length() >= 19) return LocalDateTime.parse(s.substring(0, 19), DT);
|
||||
return LocalDateTime.parse(s, DT);
|
||||
} catch (Exception e) {
|
||||
try {
|
||||
return LocalDateTime.parse(s.substring(0, 10) + "T00:00:00");
|
||||
} catch (Exception e2) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private HomesBranch findBranch(Long id, JwtUser u) {
|
||||
HomesBranch b = em.find(HomesBranch.class, id);
|
||||
if (b == null || !Objects.equals(b.getGroupId(), u.getGroupId())) return null;
|
||||
return b;
|
||||
}
|
||||
|
||||
private Map<Long, String> groupNameMap(String groupId) {
|
||||
return em.createQuery(
|
||||
"select g from HomesBranchGroup g where g.groupId=:gid", HomesBranchGroup.class)
|
||||
.setParameter("gid", groupId)
|
||||
.getResultList()
|
||||
.stream()
|
||||
.collect(Collectors.toMap(HomesBranchGroup::getId, HomesBranchGroup::getName, (a, b) -> a));
|
||||
}
|
||||
|
||||
private boolean existsUserid(String gid, String userid, Long excludeId) {
|
||||
String jpql = "select count(b) from HomesBranch b where b.groupId=:gid and b.userid=:uid"
|
||||
+ (excludeId != null ? " and b.id<>:id" : "");
|
||||
var q = em.createQuery(jpql, Long.class).setParameter("gid", gid).setParameter("uid", userid.trim());
|
||||
if (excludeId != null) q.setParameter("id", excludeId);
|
||||
return q.getSingleResult() > 0;
|
||||
}
|
||||
|
||||
private boolean existsOfficeName(String gid, String officeName, Long excludeId) {
|
||||
String jpql = "select count(b) from HomesBranch b where b.groupId=:gid and b.officeName=:n"
|
||||
+ (excludeId != null ? " and b.id<>:id" : "");
|
||||
var q = em.createQuery(jpql, Long.class).setParameter("gid", gid).setParameter("n", officeName.trim());
|
||||
if (excludeId != null) q.setParameter("id", excludeId);
|
||||
return q.getSingleResult() > 0;
|
||||
}
|
||||
|
||||
private boolean existsEmail(String gid, String email, Long excludeId) {
|
||||
String jpql = "select count(b) from HomesBranch b where b.groupId=:gid and b.email=:e"
|
||||
+ (excludeId != null ? " and b.id<>:id" : "");
|
||||
var q = em.createQuery(jpql, Long.class).setParameter("gid", gid).setParameter("e", email.trim());
|
||||
if (excludeId != null) q.setParameter("id", excludeId);
|
||||
return q.getSingleResult() > 0;
|
||||
}
|
||||
|
||||
private Map<String, Object> groupMap(HomesBranchGroup g) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", g.getId());
|
||||
m.put("name", g.getName());
|
||||
m.put("seq", g.getSeq());
|
||||
return m;
|
||||
}
|
||||
|
||||
private Map<String, Object> listMap(HomesBranch b, Map<Long, String> groupNames) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", b.getId());
|
||||
m.put("name", b.getName());
|
||||
m.put("officeName", b.getOfficeName());
|
||||
m.put("userid", b.getUserid());
|
||||
m.put("tel", b.getTel());
|
||||
m.put("phone", b.getPhone());
|
||||
m.put("email", b.getEmail());
|
||||
m.put("sido", b.getSido());
|
||||
m.put("level", b.getLevel() == null ? 1 : b.getLevel());
|
||||
m.put("grant", b.getGrantFlag() == null ? 0 : b.getGrantFlag());
|
||||
m.put("branchGroupId", b.getBranchGroupId());
|
||||
m.put("branchGroupName", b.getBranchGroupId() == null ? null : groupNames.get(b.getBranchGroupId()));
|
||||
m.put("centerName", b.getCenterName());
|
||||
m.put("hasBusinessLicense", b.isHasBusinessLicense());
|
||||
m.put("isTax", b.getIsTax() == null ? 1 : b.getIsTax());
|
||||
m.put("createdAt", b.getCreatedAt() == null ? null
|
||||
: DT.format(b.getCreatedAt().atZone(KST).toLocalDateTime()));
|
||||
return m;
|
||||
}
|
||||
|
||||
private Map<String, Object> detailMap(HomesBranch b) {
|
||||
Map<String, Object> m = listMap(b, groupNameMap(b.getGroupId()));
|
||||
m.put("fax", b.getFax());
|
||||
m.put("zipcode", b.getZipcode());
|
||||
m.put("address", b.getAddress());
|
||||
m.put("addressDetail", b.getAddressDetail());
|
||||
m.put("isPersonal", !b.isHasBusinessLicense());
|
||||
m.put("accountBank", b.getAccountBank());
|
||||
m.put("accountNumber", b.getAccountNumber());
|
||||
m.put("accountName", b.getAccountName());
|
||||
m.put("contactPhone", b.getContactPhone());
|
||||
m.put("contactNateon", b.getContactNateon());
|
||||
m.put("acceptPhone", b.getAcceptPhone() == null ? 0 : b.getAcceptPhone());
|
||||
m.put("acceptNateon", b.getAcceptNateon() == null ? 0 : b.getAcceptNateon());
|
||||
m.put("memo", b.getMemo());
|
||||
m.put("adminMemo", b.getAdminMemo());
|
||||
m.put("licenseNumber", b.getLicenseNumber());
|
||||
m.put("licenseType", b.getLicenseType());
|
||||
m.put("licenseItem", b.getLicenseItem());
|
||||
m.put("notifyPhones", parseNotify(b.getNotifyPhonesJson()));
|
||||
m.put("permissionOrderDateUpdate", b.isPermissionOrderDateUpdate());
|
||||
m.put("permissionCalculate", b.isPermissionCalculate());
|
||||
m.put("permissionConfig", b.isPermissionConfig());
|
||||
m.put("permissionExcelDownload", b.isPermissionExcelDownload());
|
||||
m.put("permissionChart", b.isPermissionChart());
|
||||
return m;
|
||||
}
|
||||
|
||||
private Map<String, Object> feeEntryMap(HomesBranchFee f, String type) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", f.getId());
|
||||
m.put("type", type);
|
||||
m.put("ispName", f.getCarrier());
|
||||
m.put("carrier", f.getCarrier());
|
||||
m.put("category", "공통");
|
||||
m.put("startDate", f.getStartDate() == null ? null : DT.format(f.getStartDate()));
|
||||
return m;
|
||||
}
|
||||
|
||||
private static List<String> parseNotify(String json) {
|
||||
List<String> phones = new ArrayList<>();
|
||||
if (json != null && json.startsWith("[")) {
|
||||
String inner = json.substring(1, json.endsWith("]") ? json.length() - 1 : json.length()).trim();
|
||||
if (!inner.isEmpty()) {
|
||||
for (String part : inner.split(",")) {
|
||||
phones.add(part.trim().replace("\"", ""));
|
||||
}
|
||||
}
|
||||
}
|
||||
while (phones.size() < 6) phones.add("");
|
||||
return phones.subList(0, 6);
|
||||
}
|
||||
|
||||
private static String toNotifyJson(Object raw) {
|
||||
List<String> phones = new ArrayList<>();
|
||||
if (raw instanceof List<?> list) {
|
||||
for (Object o : list) phones.add(o == null ? "" : String.valueOf(o));
|
||||
}
|
||||
while (phones.size() < 6) phones.add("");
|
||||
StringBuilder sb = new StringBuilder("[");
|
||||
for (int i = 0; i < 6; i++) {
|
||||
if (i > 0) sb.append(',');
|
||||
sb.append('"').append(phones.get(i).replace("\"", "")).append('"');
|
||||
}
|
||||
sb.append(']');
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
private static String deriveSido(String zipcode, String sido, String address) {
|
||||
if (!blank(sido)) return sido.trim();
|
||||
if (!blank(address)) {
|
||||
String[] parts = address.trim().split("\\s+");
|
||||
if (parts.length > 0) return parts[0];
|
||||
}
|
||||
if (!blank(zipcode) && zipcode.length() >= 2) return zipcode.substring(0, 2);
|
||||
return "";
|
||||
}
|
||||
|
||||
private static boolean blank(String s) { return s == null || s.isBlank(); }
|
||||
private static String str(Object o) { return o == null ? "" : String.valueOf(o).trim(); }
|
||||
private static boolean bool(Object o) {
|
||||
return Boolean.TRUE.equals(o) || "true".equalsIgnoreCase(String.valueOf(o));
|
||||
}
|
||||
private static Long longOrNull(Object o) {
|
||||
if (o == null || "".equals(o) || "null".equals(String.valueOf(o))) return null;
|
||||
if (o instanceof Number n) return n.longValue();
|
||||
try { return Long.parseLong(String.valueOf(o)); } catch (Exception e) { return null; }
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,182 @@
|
||||
package com.bizcare.agent;
|
||||
|
||||
import jakarta.persistence.EntityManager;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneId;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 접수별 적용 수수료 현황 — billing_imeere BranchGroupFeeController@order / CenterFeeController@order.
|
||||
*
|
||||
* 적용가능 수수료가 없으면 fail("적용가능한 수수료가 없습니다.").
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/agent/homes/fee")
|
||||
@RequiredArgsConstructor
|
||||
class CareFeeController {
|
||||
private static final ZoneId KST = ZoneId.of("Asia/Seoul");
|
||||
private static final DateTimeFormatter DT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
|
||||
|
||||
private final EntityManager em;
|
||||
|
||||
@GetMapping("/order/{receptionId}")
|
||||
@Transactional(readOnly = true)
|
||||
ApiResponse orderFee(@PathVariable Long receptionId,
|
||||
@RequestParam(defaultValue = "branch") String kind,
|
||||
@AuthenticationPrincipal JwtUser u) {
|
||||
HomesReception r = em.find(HomesReception.class, receptionId);
|
||||
if (r == null || !Objects.equals(r.getGroupId(), u.getGroupId())) {
|
||||
return ApiResponse.fail("삭제되었거나 등록되지 않은 접수입니다.");
|
||||
}
|
||||
if ("center".equalsIgnoreCase(kind)) {
|
||||
return centerOrder(r, u);
|
||||
}
|
||||
return branchOrder(r, u);
|
||||
}
|
||||
|
||||
private ApiResponse branchOrder(HomesReception r, JwtUser u) {
|
||||
String branch = blank(r.getBranch()) ? "본지점" : r.getBranch().trim();
|
||||
String carrier = blank(r.getIspName()) ? (r.getCarrier() == null ? "" : r.getCarrier().trim()) : r.getIspName().trim();
|
||||
LocalDateTime orderDate = r.getOrderDate() != null ? r.getOrderDate() : LocalDateTime.now(KST);
|
||||
|
||||
HomesBranchFee alone = findLatestBranchFee(u.getGroupId(), "alone", branch, carrier, orderDate);
|
||||
HomesBranchFee group = findLatestBranchFee(u.getGroupId(), "group",
|
||||
blank(r.getBranchGroup()) ? null : r.getBranchGroup().trim(), carrier, orderDate);
|
||||
// 그룹 수수료는 branchGroupName 매칭; 없으면 동일 통신사 그룹시트 중 최신
|
||||
if (group == null) {
|
||||
group = findLatestBranchFeeByCarrier(u.getGroupId(), "group", carrier, orderDate);
|
||||
}
|
||||
|
||||
if (alone == null && group == null) {
|
||||
return ApiResponse.fail("적용가능한 수수료가 없습니다.");
|
||||
}
|
||||
|
||||
String type = "group";
|
||||
HomesBranchFee selected = group;
|
||||
if (group == null
|
||||
|| (alone != null && !alone.getStartDate().isBefore(group.getStartDate()))) {
|
||||
type = "alone";
|
||||
selected = alone;
|
||||
}
|
||||
return ApiResponse.ok(toBranchView(selected, type, branch, carrier));
|
||||
}
|
||||
|
||||
private ApiResponse centerOrder(HomesReception r, JwtUser u) {
|
||||
String center = blank(r.getProcessor()) ? "본사" : r.getProcessor().trim();
|
||||
String carrier = blank(r.getIspName()) ? (r.getCarrier() == null ? "" : r.getCarrier().trim()) : r.getIspName().trim();
|
||||
LocalDateTime orderDate = r.getOrderDate() != null ? r.getOrderDate() : LocalDateTime.now(KST);
|
||||
|
||||
HomesCenterFee fee = findLatestCenterFee(u.getGroupId(), center, carrier, orderDate);
|
||||
if (fee == null) {
|
||||
return ApiResponse.fail("적용가능한 수수료가 없습니다.");
|
||||
}
|
||||
return ApiResponse.ok(toCenterView(fee, center, carrier));
|
||||
}
|
||||
|
||||
private HomesBranchFee findLatestBranchFee(String groupId, String feeType, String branchOrGroup,
|
||||
String carrier, LocalDateTime orderDate) {
|
||||
if (blank(branchOrGroup) || blank(carrier)) return null;
|
||||
String jpql = "alone".equals(feeType)
|
||||
? "select f from HomesBranchFee f where f.groupId=:g and f.feeType='alone' and f.branchName=:bn and f.carrier=:c and f.startDate<=:d order by f.startDate desc"
|
||||
: "select f from HomesBranchFee f where f.groupId=:g and f.feeType='group' and f.branchGroupName=:bn and f.carrier=:c and f.startDate<=:d order by f.startDate desc";
|
||||
List<HomesBranchFee> list = em.createQuery(jpql, HomesBranchFee.class)
|
||||
.setParameter("g", groupId)
|
||||
.setParameter("bn", branchOrGroup)
|
||||
.setParameter("c", carrier)
|
||||
.setParameter("d", orderDate)
|
||||
.setMaxResults(1)
|
||||
.getResultList();
|
||||
return list.isEmpty() ? null : list.get(0);
|
||||
}
|
||||
|
||||
private HomesBranchFee findLatestBranchFeeByCarrier(String groupId, String feeType, String carrier,
|
||||
LocalDateTime orderDate) {
|
||||
if (blank(carrier)) return null;
|
||||
List<HomesBranchFee> list = em.createQuery(
|
||||
"select f from HomesBranchFee f where f.groupId=:g and f.feeType=:t and f.carrier=:c and f.startDate<=:d order by f.startDate desc",
|
||||
HomesBranchFee.class)
|
||||
.setParameter("g", groupId)
|
||||
.setParameter("t", feeType)
|
||||
.setParameter("c", carrier)
|
||||
.setParameter("d", orderDate)
|
||||
.setMaxResults(1)
|
||||
.getResultList();
|
||||
return list.isEmpty() ? null : list.get(0);
|
||||
}
|
||||
|
||||
private HomesCenterFee findLatestCenterFee(String groupId, String center, String carrier, LocalDateTime orderDate) {
|
||||
if (blank(center) || blank(carrier)) return null;
|
||||
List<HomesCenterFee> list = em.createQuery(
|
||||
"select f from HomesCenterFee f where f.groupId=:g and f.centerName=:cn and f.carrier=:c and f.startDate<=:d order by f.startDate desc",
|
||||
HomesCenterFee.class)
|
||||
.setParameter("g", groupId)
|
||||
.setParameter("cn", center)
|
||||
.setParameter("c", carrier)
|
||||
.setParameter("d", orderDate)
|
||||
.setMaxResults(1)
|
||||
.getResultList();
|
||||
return list.isEmpty() ? null : list.get(0);
|
||||
}
|
||||
|
||||
private Map<String, Object> toBranchView(HomesBranchFee fee, String type, String entityName, String carrier) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("kind", "branch");
|
||||
m.put("type", type);
|
||||
m.put("feeId", fee.getId());
|
||||
m.put("entityName", entityName);
|
||||
m.put("ispName", carrier);
|
||||
m.put("startDate", fee.getStartDate() == null ? "" : fee.getStartDate().format(DT));
|
||||
m.put("memo", fee.getMemo());
|
||||
m.put("products", productsOfBranch(fee.getId()));
|
||||
return m;
|
||||
}
|
||||
|
||||
private Map<String, Object> toCenterView(HomesCenterFee fee, String entityName, String carrier) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("kind", "center");
|
||||
m.put("type", "center");
|
||||
m.put("feeId", fee.getId());
|
||||
m.put("entityName", entityName);
|
||||
m.put("ispName", carrier);
|
||||
m.put("startDate", fee.getStartDate() == null ? "" : fee.getStartDate().format(DT));
|
||||
m.put("memo", fee.getMemo());
|
||||
m.put("products", productsOfCenter(fee.getId()));
|
||||
return m;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> productsOfBranch(Long feeId) {
|
||||
return em.createQuery(
|
||||
"select p from HomesBranchFeeProduct p where p.feeId=:id order by p.id",
|
||||
HomesBranchFeeProduct.class)
|
||||
.setParameter("id", feeId)
|
||||
.getResultList().stream().map(p -> {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("productType", p.getProductType());
|
||||
row.put("productName", p.getProductName());
|
||||
row.put("fee", p.getFee() == null ? 0 : p.getFee());
|
||||
return row;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> productsOfCenter(Long feeId) {
|
||||
return em.createQuery(
|
||||
"select p from HomesCenterFeeProduct p where p.feeId=:id order by p.id",
|
||||
HomesCenterFeeProduct.class)
|
||||
.setParameter("id", feeId)
|
||||
.getResultList().stream().map(p -> {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("productType", p.getProductType());
|
||||
row.put("productName", p.getProductName());
|
||||
row.put("fee", p.getFee() == null ? 0 : p.getFee());
|
||||
return row;
|
||||
}).toList();
|
||||
}
|
||||
|
||||
private static boolean blank(String s) { return s == null || s.isBlank(); }
|
||||
}
|
||||
@@ -2845,6 +2845,7 @@ class CareHomesController {
|
||||
m.put("상품관리", "product");
|
||||
m.put("계약관리", "contract");
|
||||
m.put("접수관리", "reception");
|
||||
m.put("영업점관리", "branch");
|
||||
m.put("정산관리", "invoice");
|
||||
m.put("일정관리", "pending");
|
||||
m.put("예약관리", "booking");
|
||||
@@ -2865,7 +2866,7 @@ class CareHomesController {
|
||||
Map<String, Boolean> exposure = parseMenuFlagsMap(menuFlagsRaw);
|
||||
|
||||
if (isOwner) {
|
||||
for (String id : List.of("home", "product", "contract", "reception", "invoice", "pending", "booking",
|
||||
for (String id : List.of("home", "product", "contract", "reception", "branch", "invoice", "pending", "booking",
|
||||
"customer", "abooks", "report", "setting", "cs", "bbs", "sitemap")) {
|
||||
if (Boolean.FALSE.equals(exposure.get(id))) continue;
|
||||
allowed.add(id);
|
||||
@@ -2923,7 +2924,7 @@ class CareHomesController {
|
||||
|
||||
private Map<String, Boolean> parseMenuFlagsMap(String raw) {
|
||||
Map<String, Boolean> flags = new LinkedHashMap<>();
|
||||
for (String id : List.of("home", "product", "contract", "reception", "invoice", "pending", "booking",
|
||||
for (String id : List.of("home", "product", "contract", "reception", "branch", "invoice", "pending", "booking",
|
||||
"customer", "abooks", "report", "cs", "bbs", "setting", "sitemap")) {
|
||||
flags.put(id, true);
|
||||
}
|
||||
@@ -3069,7 +3070,7 @@ class CareHomesController {
|
||||
private static final Map<String, List<String>> LEVEL_ROWS;
|
||||
static {
|
||||
Map<String, List<String>> rows = new LinkedHashMap<>();
|
||||
rows.put("menu", List.of("상품관리", "계약관리", "접수관리", "정산관리", "일정관리", "예약관리", "고객관리", "장부관리", "통계관리", "환경설정"));
|
||||
rows.put("menu", List.of("상품관리", "계약관리", "접수관리", "영업점관리", "정산관리", "일정관리", "예약관리", "고객관리", "장부관리", "통계관리", "환경설정"));
|
||||
rows.put("product", List.of("목록조회", "상품등록", "상품수정", "상품삭제", "정책관리", "엑셀다운로드"));
|
||||
rows.put("contract", List.of("목록조회", "계약등록", "계약수정", "상태변경", "엑셀다운로드"));
|
||||
rows.put("reception", List.of("목록조회", "접수등록", "접수수정", "진행변경", "엑셀다운로드"));
|
||||
@@ -3120,7 +3121,7 @@ class CareHomesController {
|
||||
for (String menu : menus) {
|
||||
boolean allow = switch (role) {
|
||||
case "총괄", "팀장", "개발자" -> true;
|
||||
case "사원" -> List.of("계약관리", "접수관리", "정산관리", "일정관리", "예약관리", "고객관리", "장부관리").contains(menu);
|
||||
case "사원" -> List.of("계약관리", "접수관리", "영업점관리", "정산관리", "일정관리", "예약관리", "고객관리", "장부관리").contains(menu);
|
||||
case "영업", "딜러" -> List.of("계약관리", "접수관리", "정산관리", "일정관리", "예약관리").contains(menu);
|
||||
default -> false;
|
||||
};
|
||||
|
||||
@@ -29,10 +29,9 @@ class CareReceptionController {
|
||||
private final EntityManager em;
|
||||
private final FileStorageService fileStorage;
|
||||
|
||||
static final List<String> PROGRESS_STATUSES = List.of(
|
||||
"1.접수완료", "2.고객통화", "3.서류검토", "4.개통완료", "5.사은품지급", "6.정산완료", "9.취소");
|
||||
static final List<String> SETTLE_STATUSES = List.of("정산대기", "정산완료", "회수대기", "회수완료");
|
||||
static final List<String> GIFT_STATUSES = List.of("미지급", "지급대기", "지급완료", "반송");
|
||||
static final List<String> PROGRESS_STATUSES = ReceptionStatuses.progressCodes();
|
||||
static final List<String> SETTLE_STATUSES = ReceptionStatuses.centerCodes();
|
||||
static final List<String> GIFT_STATUSES = ReceptionStatuses.giftCodes();
|
||||
static final List<String> CARRIERS = List.of("KT", "SKB", "LGU+", "SKT", "기타");
|
||||
static final List<String> REGIONS = List.of(
|
||||
"서울", "경기", "인천", "강원", "충북", "충남", "대전", "전북", "전남", "광주",
|
||||
@@ -47,7 +46,9 @@ class CareReceptionController {
|
||||
Map<String, Long> counts = new LinkedHashMap<>();
|
||||
counts.put("ALL", (long) filtered.size());
|
||||
for (String st : PROGRESS_STATUSES) {
|
||||
counts.put(st, filtered.stream().filter(r -> st.equals(r.getProgressStatus())).count());
|
||||
counts.put(st, filtered.stream()
|
||||
.filter(r -> st.equals(ReceptionStatuses.normalizeProgress(r.getProgressStatus())))
|
||||
.count());
|
||||
}
|
||||
int fromIdx = Math.max(0, page) * Math.max(1, size);
|
||||
int limit = Math.min(Math.max(size, 1), 200);
|
||||
@@ -60,6 +61,10 @@ class CareReceptionController {
|
||||
data.put("progressStatuses", PROGRESS_STATUSES);
|
||||
data.put("settleStatuses", SETTLE_STATUSES);
|
||||
data.put("giftStatuses", GIFT_STATUSES);
|
||||
data.put("progressStatusLabels", ReceptionStatuses.PROGRESS);
|
||||
data.put("giftStatusLabels", ReceptionStatuses.GIFT);
|
||||
data.put("settleStatusLabels", ReceptionStatuses.CENTER);
|
||||
data.put("giftPlaceLabels", ReceptionStatuses.GIFT_PLACE);
|
||||
data.put("carriers", distinct(filtered, HomesReception::getCarrier, CARRIERS));
|
||||
data.put("branches", distinct(filtered, HomesReception::getBranch, List.of()));
|
||||
data.put("counselors", distinct(filtered, HomesReception::getCounselor, List.of()));
|
||||
@@ -170,32 +175,61 @@ class CareReceptionController {
|
||||
boolean adminOnly = Boolean.TRUE.equals(body.get("adminOnly")) || "true".equalsIgnoreCase(String.valueOf(body.get("adminOnly")));
|
||||
|
||||
boolean statusChanged = false;
|
||||
if (!blank(newStatus) && !Objects.equals(newStatus, r.getProgressStatus())) {
|
||||
r.setProgressStatus(newStatus);
|
||||
statusChanged = true;
|
||||
if ("4.개통완료".equals(newStatus) && blank(r.getGiftStatus())) {
|
||||
r.setGiftStatus("지급대기");
|
||||
}
|
||||
if ("4.개통완료".equals(newStatus) && r.getOpenDate() == null) {
|
||||
r.setOpenDate(LocalDate.now(ZoneId.of("Asia/Seoul")));
|
||||
String prevProgress = ReceptionStatuses.normalizeProgress(r.getProgressStatus());
|
||||
if (!blank(newStatus)) {
|
||||
newStatus = ReceptionStatuses.normalizeProgress(newStatus);
|
||||
if (!Objects.equals(newStatus, prevProgress)) {
|
||||
Set<String> allowed = ReceptionStatuses.orderStatusTransitions(prevProgress);
|
||||
if (("f23".equals(prevProgress) || "f24".equals(prevProgress) || "f25".equals(prevProgress))
|
||||
&& !allowed.isEmpty() && !allowed.contains(newStatus)) {
|
||||
return ApiResponse.fail("정산 관련 상태는 정산현황 메뉴에서 변경하세요. (현재: "
|
||||
+ ReceptionStatuses.progressLabel(prevProgress) + ")");
|
||||
}
|
||||
r.setProgressStatus(newStatus);
|
||||
statusChanged = true;
|
||||
applyOpenSideEffects(r, newStatus, u);
|
||||
}
|
||||
}
|
||||
|
||||
String giftStatus = str(body.get("giftStatus"));
|
||||
boolean giftChanged = false;
|
||||
if (!blank(giftStatus) && !Objects.equals(giftStatus, r.getGiftStatus())) {
|
||||
r.setGiftStatus(giftStatus);
|
||||
giftChanged = true;
|
||||
appendGiftLog(r, giftStatus, str(body.get("giftProgressLog")), u);
|
||||
if ("지급완료".equals(giftStatus) && "4.개통완료".equals(r.getProgressStatus())) {
|
||||
r.setProgressStatus("6.정산완료");
|
||||
r.setSettleStatus("정산완료");
|
||||
statusChanged = true;
|
||||
if (body.containsKey("giftStatus")) {
|
||||
giftStatus = ReceptionStatuses.normalizeGift(giftStatus);
|
||||
String prevGift = ReceptionStatuses.normalizeGift(r.getGiftStatus());
|
||||
if (!Objects.equals(giftStatus, prevGift)) {
|
||||
r.setGiftStatus(blank(giftStatus) ? null : giftStatus);
|
||||
if ("g01".equals(giftStatus) && r.getGiftRequested() == null) {
|
||||
r.setGiftRequested(LocalDate.now(ZoneId.of("Asia/Seoul")));
|
||||
}
|
||||
if ("g07".equals(giftStatus)) {
|
||||
if (r.getGiftSended() == null) {
|
||||
LocalDate today = LocalDate.now(ZoneId.of("Asia/Seoul"));
|
||||
r.setGiftSended(today);
|
||||
r.setGiftDate(today);
|
||||
}
|
||||
String cur = ReceptionStatuses.normalizeProgress(r.getProgressStatus());
|
||||
if ("f14".equals(cur)) {
|
||||
r.setProgressStatus("f23");
|
||||
r.setSettleStatus("h01");
|
||||
statusChanged = true;
|
||||
appendLog(r, "f23", "사은품 지급완료 후 정산대기 자동변경", u, false);
|
||||
}
|
||||
}
|
||||
appendGiftLog(r, blank(giftStatus) ? prevGift : giftStatus, str(body.get("giftProgressLog")), u);
|
||||
} else if (!blank(str(body.get("giftProgressLog")))) {
|
||||
appendGiftLog(r, r.getGiftStatus(), str(body.get("giftProgressLog")), u);
|
||||
}
|
||||
} else if (!blank(str(body.get("giftProgressLog")))) {
|
||||
appendGiftLog(r, r.getGiftStatus(), str(body.get("giftProgressLog")), u);
|
||||
}
|
||||
|
||||
String centerStatus = str(body.containsKey("centerStatus") ? body.get("centerStatus") : body.get("settleStatus"));
|
||||
if (!blank(centerStatus)) {
|
||||
centerStatus = ReceptionStatuses.normalizeCenter(centerStatus);
|
||||
if (!Objects.equals(centerStatus, ReceptionStatuses.normalizeCenter(r.getSettleStatus()))) {
|
||||
r.setSettleStatus(centerStatus);
|
||||
}
|
||||
}
|
||||
|
||||
if (statusChanged || !blank(progressMemo)) {
|
||||
appendLog(r, blank(newStatus) ? r.getProgressStatus() : newStatus, progressMemo, u, adminOnly);
|
||||
}
|
||||
@@ -210,6 +244,30 @@ class CareReceptionController {
|
||||
return ApiResponse.ok(toProgressDetail(r, u));
|
||||
}
|
||||
|
||||
/** f14 개통완료 시 사은품 g01 + 입력처 h01 + 개통일 */
|
||||
private void applyOpenSideEffects(HomesReception r, String newStatus, JwtUser u) {
|
||||
if ("f22".equals(newStatus) && r.getCancelDate() == null) {
|
||||
r.setCancelDate(LocalDate.now(ZoneId.of("Asia/Seoul")));
|
||||
}
|
||||
if (ReceptionStatuses.isPreCalc(newStatus)) {
|
||||
r.setReceptionCostId(null);
|
||||
}
|
||||
if (!"f14".equals(newStatus)) return;
|
||||
if (r.getOpenDate() == null) {
|
||||
r.setOpenDate(LocalDate.now(ZoneId.of("Asia/Seoul")));
|
||||
}
|
||||
String gift = ReceptionStatuses.normalizeGift(r.getGiftStatus());
|
||||
if (blank(gift)) {
|
||||
r.setGiftStatus("g01");
|
||||
if (r.getGiftRequested() == null) {
|
||||
r.setGiftRequested(LocalDate.now(ZoneId.of("Asia/Seoul")));
|
||||
}
|
||||
appendGiftLog(r, "g01", "개통완료 후 자동변경", u);
|
||||
}
|
||||
String center = ReceptionStatuses.normalizeCenter(r.getSettleStatus());
|
||||
r.setSettleStatus(blank(center) ? "h01" : center);
|
||||
}
|
||||
|
||||
@DeleteMapping("/receptions/progress-log/{logId}")
|
||||
@Transactional
|
||||
ApiResponse deleteProgressLog(@PathVariable Long logId, @AuthenticationPrincipal JwtUser u) {
|
||||
@@ -235,9 +293,12 @@ class CareReceptionController {
|
||||
r.setGroupId(u.getGroupId());
|
||||
apply(r, body);
|
||||
if (r.getOrderDate() == null) r.setOrderDate(LocalDateTime.now(ZoneId.of("Asia/Seoul")));
|
||||
if (blank(r.getProgressStatus())) r.setProgressStatus("1.접수완료");
|
||||
if (blank(r.getSettleStatus())) r.setSettleStatus("정산대기");
|
||||
if (blank(r.getGiftStatus())) r.setGiftStatus("미지급");
|
||||
if (blank(r.getProgressStatus())) r.setProgressStatus("f01");
|
||||
else r.setProgressStatus(ReceptionStatuses.normalizeProgress(r.getProgressStatus()));
|
||||
if (blank(r.getSettleStatus())) r.setSettleStatus("h01");
|
||||
else r.setSettleStatus(ReceptionStatuses.normalizeCenter(r.getSettleStatus()));
|
||||
if (blank(r.getGiftStatus()) || "미지급".equals(r.getGiftStatus())) r.setGiftStatus(null);
|
||||
else r.setGiftStatus(ReceptionStatuses.normalizeGift(r.getGiftStatus()));
|
||||
if (blank(r.getOrderNumber())) r.setOrderNumber(genOrderNumber());
|
||||
em.persist(r);
|
||||
em.flush();
|
||||
@@ -313,12 +374,13 @@ class CareReceptionController {
|
||||
|
||||
int n = 0;
|
||||
for (HomesReception r : rows) {
|
||||
r.setProgressStatus(status);
|
||||
String normalized = ReceptionStatuses.normalizeProgress(status);
|
||||
r.setProgressStatus(normalized);
|
||||
if (!blank(processor)) r.setProcessor(processor);
|
||||
if (opened != null) r.setOpenDate(opened);
|
||||
if (canceled != null) r.setCancelDate(canceled);
|
||||
if ("4.개통완료".equals(status) && blank(r.getGiftStatus())) r.setGiftStatus("지급대기");
|
||||
appendLog(r, status, memo, u, adminOnly);
|
||||
applyOpenSideEffects(r, normalized, u);
|
||||
appendLog(r, normalized, memo, u, adminOnly);
|
||||
n++;
|
||||
}
|
||||
writeLog(u, "RECEPTION", "접수-일괄진행", "접수 " + n + "건 진행상황을 [" + status + "]로 변경하였습니다.");
|
||||
@@ -359,7 +421,8 @@ class CareReceptionController {
|
||||
|
||||
int n = 0;
|
||||
for (HomesReception r : rows) {
|
||||
r.setGiftStatus(status);
|
||||
String normalized = ReceptionStatuses.normalizeGift(status);
|
||||
r.setGiftStatus(blank(normalized) ? null : normalized);
|
||||
if (!blank(place)) r.setGiftPlace(place);
|
||||
if (!blank(deliveryCompany)) r.setDeliveryCompany(deliveryCompany);
|
||||
if (!blank(trackingNumber)) r.setTrackingNumber(trackingNumber);
|
||||
@@ -368,7 +431,22 @@ class CareReceptionController {
|
||||
r.setGiftSended(sended);
|
||||
r.setGiftDate(sended);
|
||||
}
|
||||
appendGiftLog(r, status, giftMemo, u);
|
||||
if ("g01".equals(normalized) && r.getGiftRequested() == null) {
|
||||
r.setGiftRequested(LocalDate.now(ZoneId.of("Asia/Seoul")));
|
||||
}
|
||||
if ("g07".equals(normalized)) {
|
||||
if (r.getGiftSended() == null) {
|
||||
LocalDate today = LocalDate.now(ZoneId.of("Asia/Seoul"));
|
||||
r.setGiftSended(today);
|
||||
r.setGiftDate(today);
|
||||
}
|
||||
if ("f14".equals(ReceptionStatuses.normalizeProgress(r.getProgressStatus()))) {
|
||||
r.setProgressStatus("f23");
|
||||
r.setSettleStatus("h01");
|
||||
appendLog(r, "f23", "사은품 일괄지급완료 후 정산대기", u, false);
|
||||
}
|
||||
}
|
||||
appendGiftLog(r, blank(normalized) ? status : normalized, giftMemo, u);
|
||||
n++;
|
||||
}
|
||||
writeLog(u, "RECEPTION", "사은품-일괄", "접수 " + n + "건 사은품진행을 [" + status + "]로 변경하였습니다.");
|
||||
@@ -385,9 +463,9 @@ class CareReceptionController {
|
||||
for (Long id : ids) {
|
||||
HomesReception r = findOwned(id, u);
|
||||
if (r == null) return ApiResponse.fail("접수 리스트가 올바르지 않습니다.");
|
||||
// 입력처 정산이 비어있거나 미지정인 건만 대상
|
||||
if (!blank(r.getSettleStatus()) && !"정산대기".equals(r.getSettleStatus())
|
||||
&& !"미지정".equals(r.getSettleStatus())) {
|
||||
// 입력처 정산이 비어있거나 정산대기(h01)인 건만 대상
|
||||
String st = ReceptionStatuses.normalizeCenter(r.getSettleStatus());
|
||||
if (!blank(st) && !"h01".equals(st)) {
|
||||
return ApiResponse.fail("접수 리스트가 올바르지 않습니다.");
|
||||
}
|
||||
rows.add(r);
|
||||
@@ -395,7 +473,7 @@ class CareReceptionController {
|
||||
|
||||
int n = 0;
|
||||
for (HomesReception r : rows) {
|
||||
r.setSettleStatus("정산대기");
|
||||
r.setSettleStatus("h01");
|
||||
appendLog(r, r.getProgressStatus(), "[일괄변경] 입력처 정산대기", u, false);
|
||||
n++;
|
||||
}
|
||||
@@ -540,8 +618,10 @@ class CareReceptionController {
|
||||
r.setGiftCategory(giftCategory);
|
||||
r.setGiftPlace(giftPlaceCode);
|
||||
r.setGiftDate(parseDate(str(gift.get("giftDate"))));
|
||||
r.setGiftStatus(blank(giftType) || "a01".equals(giftType) ? "미지급" : "지급대기");
|
||||
r.setGiftCashAmount(toDecimal(gift.get("cashAmount")));
|
||||
r.setGiftStatus(blank(giftType) || "a01".equals(giftType) ? null : "g01");
|
||||
if ("g01".equals(r.getGiftStatus())) {
|
||||
r.setGiftRequested(LocalDate.now(ZoneId.of("Asia/Seoul")));
|
||||
} r.setGiftCashAmount(toDecimal(gift.get("cashAmount")));
|
||||
r.setGiftGoods(str(gift.get("goods")));
|
||||
r.setGiftGoodsPrice(toDecimal(gift.get("goodsPrice")));
|
||||
r.setGiftBank(str(gift.get("accountBank")));
|
||||
@@ -559,8 +639,8 @@ class CareReceptionController {
|
||||
r.setPaymentCardYear(str(payment.get("cardYear")));
|
||||
r.setPaymentCardMonth(str(payment.get("cardMonth")));
|
||||
r.setPaymentCardNumber(str(payment.get("cardNumber")));
|
||||
r.setProgressStatus("1.접수완료");
|
||||
r.setSettleStatus("정산대기");
|
||||
r.setProgressStatus("f01");
|
||||
r.setSettleStatus("h01");
|
||||
r.setOrderNumber(genOrderNumber());
|
||||
r.setProcessor(r.getCounselor());
|
||||
r.setDetailProgress("신규신청");
|
||||
@@ -766,9 +846,21 @@ class CareReceptionController {
|
||||
LocalDate gd = r.getGiftSended() != null ? r.getGiftSended() : r.getGiftDate();
|
||||
if (gd == null || !gd.equals(giftSended)) return false;
|
||||
}
|
||||
if (progressF != null && !progressF.equals(r.getProgressStatus())) return false;
|
||||
if (settle != null && !settle.equals(r.getSettleStatus())) return false;
|
||||
if (giftStatus != null && !giftStatus.equals(r.getGiftStatus())) return false;
|
||||
if (progressF != null) {
|
||||
String rp = ReceptionStatuses.normalizeProgress(r.getProgressStatus());
|
||||
String fp = ReceptionStatuses.normalizeProgress(progressF);
|
||||
if (!fp.equals(rp) && !progressF.equals(r.getProgressStatus())) return false;
|
||||
}
|
||||
if (settle != null) {
|
||||
String rs = ReceptionStatuses.normalizeCenter(r.getSettleStatus());
|
||||
String fs = ReceptionStatuses.normalizeCenter(settle);
|
||||
if (!fs.equals(rs) && !settle.equals(r.getSettleStatus())) return false;
|
||||
}
|
||||
if (giftStatus != null) {
|
||||
String rg = ReceptionStatuses.normalizeGift(r.getGiftStatus());
|
||||
String fg = ReceptionStatuses.normalizeGift(giftStatus);
|
||||
if (!fg.equals(rg) && !giftStatus.equals(r.getGiftStatus())) return false;
|
||||
}
|
||||
if (carrier != null && !carrier.equals(r.getCarrier()) && !carrier.equals(r.getIspName())) return false;
|
||||
if (ispName != null) {
|
||||
String hay = (nullToEmpty(r.getIspName()) + " " + nullToEmpty(r.getCarrier())).toLowerCase();
|
||||
@@ -829,7 +921,7 @@ class CareReceptionController {
|
||||
if (d.containsKey("customerName")) r.setCustomerName(str(d.get("customerName")));
|
||||
if (d.containsKey("phone")) r.setPhone(str(d.get("phone")));
|
||||
if (d.containsKey("region")) r.setRegion(str(d.get("region")));
|
||||
if (d.containsKey("progressStatus")) r.setProgressStatus(str(d.get("progressStatus")));
|
||||
if (d.containsKey("progressStatus")) r.setProgressStatus(ReceptionStatuses.normalizeProgress(str(d.get("progressStatus"))));
|
||||
if (d.containsKey("orderNumber")) r.setOrderNumber(str(d.get("orderNumber")));
|
||||
if (d.containsKey("ispId")) r.setIspId(toLong(d.get("ispId")));
|
||||
if (d.containsKey("ispName")) r.setIspName(str(d.get("ispName")));
|
||||
@@ -838,11 +930,13 @@ class CareReceptionController {
|
||||
if (d.containsKey("detailProgress")) r.setDetailProgress(str(d.get("detailProgress")));
|
||||
if (d.containsKey("paymentType")) r.setPaymentType(str(d.get("paymentType")));
|
||||
if (d.containsKey("amount")) r.setAmount(toDecimal(d.get("amount")));
|
||||
if (d.containsKey("settleStatus")) r.setSettleStatus(str(d.get("settleStatus")));
|
||||
if (d.containsKey("settleStatus")) r.setSettleStatus(ReceptionStatuses.normalizeCenter(str(d.get("settleStatus"))));
|
||||
if (d.containsKey("openDate")) r.setOpenDate(parseDate(str(d.get("openDate"))));
|
||||
if (d.containsKey("giftDate")) r.setGiftDate(parseDate(str(d.get("giftDate"))));
|
||||
if (d.containsKey("giftStatus")) r.setGiftStatus(str(d.get("giftStatus")));
|
||||
if (d.containsKey("giftPlace")) r.setGiftPlace(str(d.get("giftPlace")));
|
||||
if (d.containsKey("giftStatus")) {
|
||||
String g = ReceptionStatuses.normalizeGift(str(d.get("giftStatus")));
|
||||
r.setGiftStatus(blank(g) ? null : g);
|
||||
} if (d.containsKey("giftPlace")) r.setGiftPlace(str(d.get("giftPlace")));
|
||||
if (d.containsKey("agreementName")) r.setAgreementName(str(d.get("agreementName")));
|
||||
if (d.containsKey("contractNumber")) r.setContractNumber(str(d.get("contractNumber")));
|
||||
if (d.containsKey("identifyNumber")) r.setIdentifyNumber(str(d.get("identifyNumber")));
|
||||
@@ -953,12 +1047,14 @@ class CareReceptionController {
|
||||
data.put("progressStatuses", PROGRESS_STATUSES);
|
||||
data.put("giftStatuses", GIFT_STATUSES);
|
||||
data.put("settleStatuses", SETTLE_STATUSES);
|
||||
Map<String, String> statusOptions = new LinkedHashMap<>();
|
||||
for (String s : PROGRESS_STATUSES) statusOptions.put(s, s);
|
||||
data.put("statusOptions", statusOptions);
|
||||
Map<String, String> giftStatusOptions = new LinkedHashMap<>();
|
||||
for (String s : GIFT_STATUSES) giftStatusOptions.put(s, s);
|
||||
data.put("giftStatusOptions", giftStatusOptions);
|
||||
data.put("progressStatusLabels", ReceptionStatuses.PROGRESS);
|
||||
data.put("giftStatusLabels", ReceptionStatuses.GIFT);
|
||||
data.put("settleStatusLabels", ReceptionStatuses.CENTER);
|
||||
data.put("statusOptions", new LinkedHashMap<>(ReceptionStatuses.PROGRESS));
|
||||
data.put("giftStatusOptions", new LinkedHashMap<>(ReceptionStatuses.GIFT));
|
||||
data.put("centerStatusOptions", new LinkedHashMap<>(ReceptionStatuses.CENTER));
|
||||
data.put("allowedProgressStatuses", new ArrayList<>(
|
||||
ReceptionStatuses.orderStatusTransitions(ReceptionStatuses.normalizeProgress(r.getProgressStatus()))));
|
||||
// flat alias for list edit form compatibility
|
||||
data.putAll(reception);
|
||||
return data;
|
||||
@@ -1003,6 +1099,8 @@ class CareReceptionController {
|
||||
m.put("addressDetail", r.getAddressDetail());
|
||||
m.put("region", r.getRegion());
|
||||
m.put("progressStatus", r.getProgressStatus());
|
||||
m.put("progressStatusLabel", ReceptionStatuses.progressLabel(ReceptionStatuses.normalizeProgress(r.getProgressStatus())));
|
||||
m.put("status", ReceptionStatuses.progressLabel(ReceptionStatuses.normalizeProgress(r.getProgressStatus())));
|
||||
m.put("orderNumber", r.getOrderNumber());
|
||||
m.put("ispId", r.getIspId());
|
||||
m.put("ispName", r.getIspName());
|
||||
@@ -1038,8 +1136,11 @@ class CareReceptionController {
|
||||
m.put("sido", r.getRegion());
|
||||
m.put("opened", r.getOpenDate() == null ? null : r.getOpenDate().toString());
|
||||
m.put("canceled", r.getCancelDate() == null ? null : r.getCancelDate().toString());
|
||||
m.put("status", r.getProgressStatus());
|
||||
m.put("giftType", r.getGiftCategory());
|
||||
m.put("giftStatusLabel", ReceptionStatuses.giftLabel(ReceptionStatuses.normalizeGift(r.getGiftStatus())));
|
||||
m.put("settleStatusLabel", ReceptionStatuses.centerLabel(ReceptionStatuses.normalizeCenter(r.getSettleStatus())));
|
||||
m.put("centerStatus", r.getSettleStatus());
|
||||
m.put("centerStatusLabel", ReceptionStatuses.centerLabel(ReceptionStatuses.normalizeCenter(r.getSettleStatus())));
|
||||
m.put("giftSended", r.getGiftSended() == null
|
||||
? (r.getGiftDate() == null ? null : r.getGiftDate().toString())
|
||||
: r.getGiftSended().toString());
|
||||
@@ -1050,7 +1151,8 @@ class CareReceptionController {
|
||||
if (r.getGiftCashAmount() != null) giftAmt = giftAmt.add(r.getGiftCashAmount());
|
||||
if (r.getGiftGoodsPrice() != null) giftAmt = giftAmt.add(r.getGiftGoodsPrice());
|
||||
if (giftAmt.signum() == 0 && r.getAmount() != null
|
||||
&& r.getGiftStatus() != null && !"미지급".equals(r.getGiftStatus()) && !"없음".equals(r.getGiftCategory())) {
|
||||
&& r.getGiftStatus() != null && !r.getGiftStatus().isBlank()
|
||||
&& !"미지급".equals(r.getGiftStatus()) && !"없음".equals(r.getGiftCategory())) {
|
||||
giftAmt = r.getAmount();
|
||||
}
|
||||
m.put("giftAmount", giftAmt.signum() == 0 ? null : giftAmt);
|
||||
|
||||
@@ -71,8 +71,28 @@ class DataInitializer implements CommandLineRunner {
|
||||
seedScheduleSampleIfEmpty();
|
||||
seedPostitsIfEmpty();
|
||||
seedStockInfoSampleIfEmpty();
|
||||
migrateReceptionStatusesToCodes();
|
||||
seedCalculateSamplesIfEmpty();
|
||||
seedFeeSheetsIfEmpty();
|
||||
seedBranchesIfEmpty();
|
||||
}
|
||||
|
||||
/** 구 한글 진행/사은품/입력처 상태를 billing 코드(f/g/h)로 변환 */
|
||||
private void migrateReceptionStatusesToCodes() {
|
||||
List<HomesReception> rows = em.createQuery("select r from HomesReception r", HomesReception.class).getResultList();
|
||||
for (HomesReception r : rows) {
|
||||
String p = ReceptionStatuses.normalizeProgress(r.getProgressStatus());
|
||||
if (p != null && !p.equals(r.getProgressStatus())) r.setProgressStatus(p.isBlank() ? null : p);
|
||||
String g = ReceptionStatuses.normalizeGift(r.getGiftStatus());
|
||||
if (g != null && !g.equals(nullToEmpty(r.getGiftStatus()))) r.setGiftStatus(g.isBlank() ? null : g);
|
||||
String c = ReceptionStatuses.normalizeCenter(r.getSettleStatus());
|
||||
if (c != null && !c.equals(nullToEmpty(r.getSettleStatus()))) r.setSettleStatus(c.isBlank() ? null : c);
|
||||
}
|
||||
em.flush();
|
||||
}
|
||||
|
||||
private static String nullToEmpty(String s) { return s == null ? "" : s; }
|
||||
|
||||
private void seedStockInfoSampleIfEmpty() {
|
||||
Long exists = em.createQuery("select count(s) from Stock s where s.groupId='demo' and s.serial='0018987'", Long.class).getSingleResult();
|
||||
if (exists != null && exists > 0) return;
|
||||
@@ -2325,16 +2345,16 @@ class DataInitializer implements CommandLineRunner {
|
||||
em.flush();
|
||||
if (em.createQuery("select count(r) from HomesReception r where r.groupId='demo'", Long.class).getSingleResult() == 0) {
|
||||
Object[][] rows = {
|
||||
{LocalDateTime.of(2026, 7, 10, 9, 12), "김상담", "강남점", "KT", "상품권", "인터넷", "기가인터넷 1G", "홍*동", "010-1111-2222", "서울", "1.접수완료", "SAMPLE-00001", "서울입고", "박처리", "접수", "현금", "100000", "정산대기", null, null, "미지급", "본사", "36개월", "02-1234-5678"},
|
||||
{LocalDateTime.of(2026, 7, 10, 10, 30), "이상담", "서초점", "SKB", "가전", "TV", "B tv 프리미엄", "김*수", "010-2222-3333", "경기", "2.고객통화", "SAMPLE-00002", "", "최처리", "통화완료", "계좌", "80000", "정산대기", null, null, "지급대기", "영업점", "24개월", ""},
|
||||
{LocalDateTime.of(2026, 7, 9, 14, 5), "김상담", "강남점", "LGU+", "상품권", "인터넷", "U+ 인터넷", "박*지", "010-3333-4444", "인천", "4.개통완료", "SAMPLE-00003", "서울입고", "박처리", "개통", "현금", "120000", "정산대기", LocalDate.of(2026, 7, 9), null, "미지급", "본사", "36개월", "032-555-1212"},
|
||||
{LocalDateTime.of(2026, 7, 8, 11, 20), "정상담", "송파점", "KT", "캐시백", "정수기", "청호 450", "최*아", "010-4444-5555", "서울", "4.개통완료", "SAMPLE-00004", "물류완료", "이처리", "설치완료", "카드", "150000", "정산완료", LocalDate.of(2026, 7, 8), LocalDate.of(2026, 7, 10), "지급완료", "본사", "60개월", ""},
|
||||
{LocalDateTime.of(2026, 7, 8, 16, 40), "이상담", "서초점", "SKT", "상품권", "전화", "집전화 스마트", "정*민", "010-5555-6666", "부산", "3.서류검토", "SAMPLE-00005", "", "최처리", "서류요청", "현금", "15000", "정산대기", null, null, "미지급", "본사", "24개월", ""},
|
||||
{LocalDateTime.of(2026, 7, 7, 9, 50), "김상담", "강남점", "KT", "가전", "공기청정기", "퓨리케어 360", "이*수", "010-6666-7777", "서울", "5.사은품지급", "SAMPLE-00006", "서울입고", "박처리", "사은품발송", "계좌", "90000", "정산대기", LocalDate.of(2026, 7, 6), LocalDate.of(2026, 7, 7), "지급완료", "본사", "36개월", ""},
|
||||
{LocalDateTime.of(2026, 7, 6, 13, 15), "정상담", "송파점", "SKB", "상품권", "인터넷", "B tv 인터넷", "윤*희", "010-7777-8888", "경기", "6.정산완료", "SAMPLE-00007", "완료", "이처리", "정산", "현금", "75000", "정산완료", LocalDate.of(2026, 7, 5), LocalDate.of(2026, 7, 6), "지급완료", "영업점", "36개월", ""},
|
||||
{LocalDateTime.of(2026, 7, 5, 15, 0), "이상담", "서초점", "LGU+", "캐시백", "와이파이", "와이파이 메시", "한*진", "010-8888-9999", "대전", "9.취소", "SAMPLE-00008", "", "최처리", "고객취소", "", "0", "회수대기", null, null, "반송", "", "12개월", ""},
|
||||
{LocalDateTime.of(2026, 7, 11, 8, 45), "김상담", "강남점", "KT", "상품권", "인터넷", "안심 인터넷 베이직", "오*라", "010-1212-3434", "서울", "1.접수완료", "SAMPLE-00009", "", "박처리", "접수", "현금", "60000", "정산대기", null, null, "미지급", "본사", "36개월", ""},
|
||||
{LocalDateTime.of(2026, 7, 11, 11, 10), "정상담", "송파점", "SKB", "가전", "비데", "스스로케어 비데", "서*호", "010-5656-7878", "경남", "2.고객통화", "SAMPLE-00010", "물류대기", "이처리", "통화예약", "계좌", "60000", "정산대기", null, null, "지급대기", "본사", "36개월", ""},
|
||||
{LocalDateTime.of(2026, 7, 10, 9, 12), "김상담", "강남점", "KT", "상품권", "인터넷", "기가인터넷 1G", "홍*동", "010-1111-2222", "서울", "f01", "SAMPLE-00001", "서울입고", "박처리", "접수", "현금", "100000", "h01", null, null, "", "b01", "36개월", "02-1234-5678"},
|
||||
{LocalDateTime.of(2026, 7, 10, 10, 30), "이상담", "서초점", "SKB", "가전", "TV", "B tv 프리미엄", "김*수", "010-2222-3333", "경기", "f03", "SAMPLE-00002", "", "최처리", "통화완료", "계좌", "80000", "h01", null, null, "g01", "b02", "24개월", ""},
|
||||
{LocalDateTime.of(2026, 7, 9, 14, 5), "김상담", "강남점", "LGU+", "상품권", "인터넷", "U+ 인터넷", "박*지", "010-3333-4444", "인천", "f14", "SAMPLE-00003", "서울입고", "박처리", "개통", "현금", "120000", "h01", LocalDate.of(2026, 7, 9), null, "g01", "b01", "36개월", "032-555-1212"},
|
||||
{LocalDateTime.of(2026, 7, 8, 11, 20), "정상담", "송파점", "KT", "캐시백", "정수기", "청호 450", "최*아", "010-4444-5555", "서울", "f23", "SAMPLE-00004", "물류완료", "이처리", "설치완료", "카드", "150000", "h01", LocalDate.of(2026, 7, 8), LocalDate.of(2026, 7, 10), "g07", "b01", "60개월", ""},
|
||||
{LocalDateTime.of(2026, 7, 8, 16, 40), "이상담", "서초점", "SKT", "상품권", "전화", "집전화 스마트", "정*민", "010-5555-6666", "부산", "f05", "SAMPLE-00005", "", "최처리", "서류요청", "현금", "15000", "h01", null, null, "", "b01", "24개월", ""},
|
||||
{LocalDateTime.of(2026, 7, 7, 9, 50), "김상담", "강남점", "KT", "가전", "공기청정기", "퓨리케어 360", "이*수", "010-6666-7777", "서울", "f14", "SAMPLE-00006", "서울입고", "박처리", "사은품발송", "계좌", "90000", "h01", LocalDate.of(2026, 7, 6), LocalDate.of(2026, 7, 7), "g07", "b01", "36개월", ""},
|
||||
{LocalDateTime.of(2026, 7, 6, 13, 15), "정상담", "송파점", "SKB", "상품권", "인터넷", "B tv 인터넷", "윤*희", "010-7777-8888", "경기", "f24", "SAMPLE-00007", "완료", "이처리", "정산", "현금", "75000", "h02", LocalDate.of(2026, 7, 5), LocalDate.of(2026, 7, 6), "g07", "b02", "36개월", ""},
|
||||
{LocalDateTime.of(2026, 7, 5, 15, 0), "이상담", "서초점", "LGU+", "캐시백", "와이파이", "와이파이 메시", "한*진", "010-8888-9999", "대전", "f11", "SAMPLE-00008", "", "최처리", "고객취소", "", "0", "h05", null, null, "g03", "", "12개월", ""},
|
||||
{LocalDateTime.of(2026, 7, 11, 8, 45), "김상담", "강남점", "KT", "상품권", "인터넷", "안심 인터넷 베이직", "오*라", "010-1212-3434", "서울", "f01", "SAMPLE-00009", "", "박처리", "접수", "현금", "60000", "h01", null, null, "", "b01", "36개월", ""},
|
||||
{LocalDateTime.of(2026, 7, 11, 11, 10), "정상담", "송파점", "SKB", "가전", "비데", "스스로케어 비데", "서*호", "010-5656-7878", "경남", "f03", "SAMPLE-00010", "물류대기", "이처리", "통화예약", "계좌", "60000", "h01", null, null, "g01", "b01", "36개월", ""},
|
||||
};
|
||||
for (Object[] s : rows) {
|
||||
HomesReception r = new HomesReception();
|
||||
@@ -2359,7 +2379,7 @@ class DataInitializer implements CommandLineRunner {
|
||||
r.setSettleStatus(String.valueOf(s[17]));
|
||||
r.setOpenDate(s[18] == null ? null : (LocalDate) s[18]);
|
||||
r.setGiftDate(s[19] == null ? null : (LocalDate) s[19]);
|
||||
r.setGiftStatus(String.valueOf(s[20]));
|
||||
r.setGiftStatus(String.valueOf(s[20]).isBlank() ? null : String.valueOf(s[20]));
|
||||
r.setGiftPlace(String.valueOf(s[21]));
|
||||
r.setAgreementName(String.valueOf(s[22]));
|
||||
r.setContractNumber(String.valueOf(s[23]));
|
||||
@@ -2841,7 +2861,8 @@ class DataInitializer implements CommandLineRunner {
|
||||
if (r.getTel() == null || r.getTel().isBlank()) r.setTel("02-100" + i);
|
||||
if (r.getGiftSended() == null && r.getGiftDate() != null) r.setGiftSended(r.getGiftDate());
|
||||
if (r.getGiftCashAmount() == null && r.getAmount() != null
|
||||
&& r.getGiftStatus() != null && !"미지급".equals(r.getGiftStatus()) && !"없음".equals(r.getGiftCategory())) {
|
||||
&& r.getGiftStatus() != null && !r.getGiftStatus().isBlank()
|
||||
&& !"미지급".equals(r.getGiftStatus()) && !"없음".equals(r.getGiftCategory())) {
|
||||
r.setGiftCashAmount(r.getAmount());
|
||||
}
|
||||
if (r.getIspName() == null || r.getIspName().isBlank()) r.setIspName(r.getCarrier());
|
||||
@@ -2962,4 +2983,485 @@ class DataInitializer implements CommandLineRunner {
|
||||
em.persist(s);
|
||||
return s;
|
||||
}
|
||||
|
||||
/**
|
||||
* billing_react 정산현황 테스트용 샘플.
|
||||
* 영업점/입력처 각각 가정산·정산대기·정산완료·송금(입금)완료 단계를 채운다.
|
||||
*/
|
||||
private void seedCalculateSamplesIfEmpty() {
|
||||
Long exists = em.createQuery(
|
||||
"select count(r) from HomesReception r where r.groupId='demo' and r.orderNumber like 'CALC-%'",
|
||||
Long.class)
|
||||
.getSingleResult();
|
||||
if (exists != null && exists > 0) return;
|
||||
|
||||
LocalDateTime now = LocalDateTime.now(ZoneId.of("Asia/Seoul"));
|
||||
String[][] branches = {
|
||||
{"강남점", "본사그룹", "서울", "김상담", "박처리", "1599-1001"},
|
||||
{"서초점", "본사그룹", "경기", "이상담", "최처리", "1599-1002"},
|
||||
{"송파점", "동부그룹", "서울", "정상담", "이처리", "1599-1003"},
|
||||
};
|
||||
String[] carriers = {"KT", "SKB", "LGU+"};
|
||||
String[] products = {"기가인터넷 1G", "B tv 프리미엄", "U+ 인터넷", "안심 인터넷"};
|
||||
int seq = 1;
|
||||
|
||||
// ---- 영업점: 가정산 (cost 없음, f23 / 환수 f19·f22) ----
|
||||
for (String[] b : branches) {
|
||||
for (int i = 0; i < 3; i++) {
|
||||
HomesReception r = calcReception(
|
||||
"CALC-" + String.format("%04d", seq++),
|
||||
now.minusDays(3 + i), b, carriers[i % 3], products[i % 4],
|
||||
"정산고객" + seq, "f23", "h01", 100000 + i * 10000, true);
|
||||
em.persist(r);
|
||||
}
|
||||
HomesReception claw = calcReception(
|
||||
"CALC-" + String.format("%04d", seq++),
|
||||
now.minusDays(20), b, carriers[0], products[0],
|
||||
"환수고객" + seq, "f19", "h05", 80000, true);
|
||||
claw.setPrevProgressStatus("f14");
|
||||
em.persist(claw);
|
||||
}
|
||||
|
||||
// ---- 영업점: 정산대기 (cost 있음, group 없음) ----
|
||||
for (String[] b : branches) {
|
||||
for (int i = 0; i < 2; i++) {
|
||||
HomesReception r = calcReception(
|
||||
"CALC-" + String.format("%04d", seq++),
|
||||
now.minusDays(10 + i), b, carriers[i % 3], products[i % 4],
|
||||
"대기고객" + seq, "f23", "h01", 120000 + i * 5000, true);
|
||||
em.persist(r);
|
||||
em.flush();
|
||||
HomesReceptionCost c = calcCost(r, "i01", 1);
|
||||
em.persist(c);
|
||||
em.flush();
|
||||
r.setReceptionCostId(c.getId());
|
||||
}
|
||||
HomesReception claw = calcReception(
|
||||
"CALC-" + String.format("%04d", seq++),
|
||||
now.minusDays(25), b, carriers[1], products[1],
|
||||
"대기환수" + seq, "f22", "h08", 70000, true);
|
||||
claw.setPrevProgressStatus("f14");
|
||||
em.persist(claw);
|
||||
em.flush();
|
||||
HomesReceptionCost cc = calcCost(claw, "i05", -1);
|
||||
em.persist(cc);
|
||||
em.flush();
|
||||
claw.setReceptionCostId(cc.getId());
|
||||
}
|
||||
|
||||
// ---- 영업점: 정산완료 그룹 (미송금) ----
|
||||
for (String[] b : branches) {
|
||||
HomesReceptionCostGroup g = new HomesReceptionCostGroup();
|
||||
g.setGroupId("demo");
|
||||
g.setBranchName(b[0]);
|
||||
g.setPlusAmt(5000);
|
||||
g.setMinusAmt(0);
|
||||
g.setIsTax(1);
|
||||
g.setRate(BigDecimal.valueOf(10));
|
||||
g.setCompletedAt(now.minusDays(2));
|
||||
g.setMemo("샘플 정산완료 — " + b[0]);
|
||||
g.setBank("국민");
|
||||
g.setAccountNumber("123-456-" + b[0].hashCode() % 10000);
|
||||
g.setAccountName(b[0] + "대표");
|
||||
em.persist(g);
|
||||
em.flush();
|
||||
|
||||
int lineSum = 0;
|
||||
for (int i = 0; i < 2; i++) {
|
||||
HomesReception r = calcReception(
|
||||
"CALC-" + String.format("%04d", seq++),
|
||||
now.minusDays(15 + i), b, carriers[i % 3], products[i % 4],
|
||||
"완료고객" + seq, "f24", "h02", 110000 + i * 8000, true);
|
||||
em.persist(r);
|
||||
em.flush();
|
||||
HomesReceptionCost c = calcCost(r, "i01", 1);
|
||||
c.setCostGroupId(g.getId());
|
||||
em.persist(c);
|
||||
em.flush();
|
||||
r.setReceptionCostId(c.getId());
|
||||
lineSum += c.getTotal();
|
||||
}
|
||||
// 환수 1건
|
||||
HomesReception claw = calcReception(
|
||||
"CALC-" + String.format("%04d", seq++),
|
||||
now.minusDays(30), b, carriers[2], products[2],
|
||||
"완료환수" + seq, "f26", "h09", 50000, true);
|
||||
em.persist(claw);
|
||||
em.flush();
|
||||
HomesReceptionCost cc = calcCost(claw, "i02", -1);
|
||||
cc.setCostGroupId(g.getId());
|
||||
em.persist(cc);
|
||||
em.flush();
|
||||
claw.setReceptionCostId(cc.getId());
|
||||
lineSum += cc.getTotal();
|
||||
g.setTotal(g.getPlusAmt() + g.getMinusAmt() + lineSum);
|
||||
}
|
||||
|
||||
// ---- 영업점: 송금완료 그룹 ----
|
||||
for (String[] b : branches) {
|
||||
HomesReceptionCostGroup g = new HomesReceptionCostGroup();
|
||||
g.setGroupId("demo");
|
||||
g.setBranchName(b[0]);
|
||||
g.setPlusAmt(0);
|
||||
g.setMinusAmt(0);
|
||||
g.setIsTax(1);
|
||||
g.setRate(BigDecimal.valueOf(10));
|
||||
g.setTaxDate(now.minusDays(8));
|
||||
g.setCompletedAt(now.minusDays(7));
|
||||
g.setTransferredAt(now.minusDays(5));
|
||||
g.setMemo("샘플 송금완료 — " + b[0]);
|
||||
g.setBank("신한");
|
||||
g.setAccountNumber("110-222-" + Math.abs(b[0].hashCode() % 10000));
|
||||
g.setAccountName(b[0]);
|
||||
em.persist(g);
|
||||
em.flush();
|
||||
|
||||
int lineSum = 0;
|
||||
for (int i = 0; i < 2; i++) {
|
||||
HomesReception r = calcReception(
|
||||
"CALC-" + String.format("%04d", seq++),
|
||||
now.minusDays(40 + i), b, carriers[i % 3], products[i % 4],
|
||||
"송금고객" + seq, "f25", "h03", 130000 + i * 7000, true);
|
||||
em.persist(r);
|
||||
em.flush();
|
||||
HomesReceptionCost c = calcCost(r, "i01", 1);
|
||||
c.setCostGroupId(g.getId());
|
||||
em.persist(c);
|
||||
em.flush();
|
||||
r.setReceptionCostId(c.getId());
|
||||
lineSum += c.getTotal();
|
||||
}
|
||||
g.setTotal(lineSum);
|
||||
}
|
||||
|
||||
// ---- 입력처: 가정산 (center cost 없음) — 영업점 대기와 별도 접수 ----
|
||||
for (String[] b : branches) {
|
||||
for (int i = 0; i < 2; i++) {
|
||||
HomesReception r = calcReception(
|
||||
"CALC-" + String.format("%04d", seq++),
|
||||
now.minusDays(4 + i), b, carriers[i % 3], products[i % 4],
|
||||
"입력가정산" + seq, "f14", "h01", 90000 + i * 3000, true);
|
||||
// 영업점 정산과는 분리: 개통완료+입력처 정산대기
|
||||
em.persist(r);
|
||||
}
|
||||
HomesReception claw = calcReception(
|
||||
"CALC-" + String.format("%04d", seq++),
|
||||
now.minusDays(18), b, carriers[0], products[0],
|
||||
"입력환수" + seq, "f14", "h05", 60000, true);
|
||||
em.persist(claw);
|
||||
}
|
||||
|
||||
// ---- 입력처: 정산대기 ----
|
||||
for (String[] b : branches) {
|
||||
for (int i = 0; i < 2; i++) {
|
||||
HomesReception r = calcReception(
|
||||
"CALC-" + String.format("%04d", seq++),
|
||||
now.minusDays(12 + i), b, carriers[i % 3], products[i % 4],
|
||||
"입력대기" + seq, "f14", "h01", 95000 + i * 4000, true);
|
||||
em.persist(r);
|
||||
em.flush();
|
||||
HomesReceptionCenterCost c = calcCenterCost(r, "k01", 1);
|
||||
em.persist(c);
|
||||
em.flush();
|
||||
r.setReceptionCenterCostId(c.getId());
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 입력처: 정산완료 그룹 ----
|
||||
for (String[] b : branches) {
|
||||
HomesReceptionCenterCostGroup g = new HomesReceptionCenterCostGroup();
|
||||
g.setGroupId("demo");
|
||||
g.setCenterName(b[4]); // processor
|
||||
g.setPlusAmt(3000);
|
||||
g.setMinusAmt(0);
|
||||
g.setIsTax(1);
|
||||
g.setRate(BigDecimal.valueOf(10));
|
||||
g.setCompletedAt(now.minusDays(3));
|
||||
g.setMemo("샘플 입력처 정산완료 — " + b[4]);
|
||||
em.persist(g);
|
||||
em.flush();
|
||||
|
||||
int lineSum = 0;
|
||||
for (int i = 0; i < 2; i++) {
|
||||
HomesReception r = calcReception(
|
||||
"CALC-" + String.format("%04d", seq++),
|
||||
now.minusDays(16 + i), b, carriers[i % 3], products[i % 4],
|
||||
"입력완료" + seq, "f14", "h02", 88000 + i * 2000, true);
|
||||
em.persist(r);
|
||||
em.flush();
|
||||
HomesReceptionCenterCost c = calcCenterCost(r, "k01", 1);
|
||||
c.setCostGroupId(g.getId());
|
||||
em.persist(c);
|
||||
em.flush();
|
||||
r.setReceptionCenterCostId(c.getId());
|
||||
lineSum += c.getTotal();
|
||||
}
|
||||
g.setTotal(g.getPlusAmt() + lineSum);
|
||||
}
|
||||
|
||||
// ---- 입력처: 입금완료 그룹 ----
|
||||
for (String[] b : branches) {
|
||||
HomesReceptionCenterCostGroup g = new HomesReceptionCenterCostGroup();
|
||||
g.setGroupId("demo");
|
||||
g.setCenterName(b[4]);
|
||||
g.setPlusAmt(0);
|
||||
g.setMinusAmt(0);
|
||||
g.setIsTax(1);
|
||||
g.setRate(BigDecimal.valueOf(10));
|
||||
g.setCompletedAt(now.minusDays(9));
|
||||
g.setTransferredAt(now.minusDays(6));
|
||||
g.setMemo("샘플 입금완료 — " + b[4]);
|
||||
em.persist(g);
|
||||
em.flush();
|
||||
|
||||
int lineSum = 0;
|
||||
HomesReception r = calcReception(
|
||||
"CALC-" + String.format("%04d", seq++),
|
||||
now.minusDays(35), b, carriers[0], products[0],
|
||||
"입력입금" + seq, "f14", "h03", 77000, true);
|
||||
em.persist(r);
|
||||
em.flush();
|
||||
HomesReceptionCenterCost c = calcCenterCost(r, "k01", 1);
|
||||
c.setCostGroupId(g.getId());
|
||||
em.persist(c);
|
||||
em.flush();
|
||||
r.setReceptionCenterCostId(c.getId());
|
||||
lineSum += c.getTotal();
|
||||
g.setTotal(lineSum);
|
||||
}
|
||||
|
||||
em.flush();
|
||||
}
|
||||
|
||||
private HomesReception calcReception(
|
||||
String orderNo, LocalDateTime orderDate, String[] branch,
|
||||
String carrier, String product, String customer,
|
||||
String progress, String settle, int amount, boolean opened) {
|
||||
HomesReception r = new HomesReception();
|
||||
r.setGroupId("demo");
|
||||
r.setOrderDate(orderDate);
|
||||
r.setCounselor(branch[3]);
|
||||
r.setBranch(branch[0]);
|
||||
r.setBranchGroup(branch[1]);
|
||||
r.setBranchPhone(branch[5]);
|
||||
r.setRegion(branch[2]);
|
||||
r.setProcessor(branch[4]);
|
||||
r.setCarrier(carrier);
|
||||
r.setIspName(carrier);
|
||||
r.setGiftCategory("상품권");
|
||||
r.setProductCategory("인터넷");
|
||||
r.setProductType("e01");
|
||||
r.setProductName(product);
|
||||
r.setCustomerType("public");
|
||||
r.setCustomerName(customer);
|
||||
r.setPhone("010-" + String.format("%04d", Math.abs(orderNo.hashCode() % 10000)) + "-"
|
||||
+ String.format("%04d", Math.abs(customer.hashCode() % 10000)));
|
||||
r.setProgressStatus(progress);
|
||||
r.setOrderNumber(orderNo);
|
||||
r.setContractNumber(orderNo.replace("CALC-", "CT-"));
|
||||
r.setInboundInfo("서울입고");
|
||||
r.setDetailProgress("정산샘플");
|
||||
r.setPaymentType("현금");
|
||||
r.setPaymentMethod("transfer");
|
||||
r.setAmount(BigDecimal.valueOf(amount));
|
||||
r.setSettleStatus(settle);
|
||||
if (opened) {
|
||||
r.setOpenDate(orderDate.toLocalDate().minusDays(1));
|
||||
r.setGiftDate(orderDate.toLocalDate());
|
||||
r.setGiftStatus("g07");
|
||||
r.setGiftPlace("b01");
|
||||
r.setGiftSended(orderDate.toLocalDate());
|
||||
}
|
||||
r.setAgreementName("36개월");
|
||||
r.setMemo("정산현황 테스트 샘플 (" + progress + "/" + settle + ")");
|
||||
r.setPostIt("");
|
||||
return r;
|
||||
}
|
||||
|
||||
private HomesReceptionCost calcCost(HomesReception r, String type, int sign) {
|
||||
HomesReceptionCost c = new HomesReceptionCost();
|
||||
c.setGroupId("demo");
|
||||
c.setReceptionId(r.getId());
|
||||
c.setType(type);
|
||||
c.setTypeDesc(ReceptionStatuses.COST_TYPE.getOrDefault(type, ""));
|
||||
int base = r.getAmount() == null ? 0 : r.getAmount().intValue();
|
||||
c.setCost(base * sign);
|
||||
c.setEventAmt(0);
|
||||
c.setAgreementAmt(0);
|
||||
c.setLocalAmt(0);
|
||||
// 사은품 차감(본사발송 가정)
|
||||
int gift = ("g07".equals(r.getGiftStatus()) ? -10000 : 0) * sign;
|
||||
c.setGiftAmt(gift);
|
||||
c.setVocAmt(0);
|
||||
c.setEtcAmt(0);
|
||||
c.setTotal(c.getCost() + c.getGiftAmt());
|
||||
c.setMemo("샘플 정산라인");
|
||||
return c;
|
||||
}
|
||||
|
||||
private HomesReceptionCenterCost calcCenterCost(HomesReception r, String type, int sign) {
|
||||
HomesReceptionCenterCost c = new HomesReceptionCenterCost();
|
||||
c.setGroupId("demo");
|
||||
c.setReceptionId(r.getId());
|
||||
c.setType(type);
|
||||
c.setTypeDesc(ReceptionStatuses.CENTER_COST_TYPE.getOrDefault(type, ""));
|
||||
int base = r.getAmount() == null ? 0 : r.getAmount().intValue();
|
||||
// 입력처 수수료는 영업점보다 낮게
|
||||
c.setCost((base / 2) * sign);
|
||||
c.setEventAmt(0);
|
||||
c.setAgreementAmt(0);
|
||||
c.setLocalAmt(0);
|
||||
c.setGiftAmt(0);
|
||||
c.setVocAmt(0);
|
||||
c.setEtcAmt(0);
|
||||
c.setTotal(c.getCost());
|
||||
c.setMemo("샘플 입력처 정산라인");
|
||||
return c;
|
||||
}
|
||||
|
||||
/**
|
||||
* 수수료 현황 팝업 테스트용 — billing_imeere branch_fees / center_fees 샘플.
|
||||
* 서초점·강남점 등은 수수료 있음, 일부 통신사 조합은 없음으로 "적용가능한 수수료가 없습니다" 검증.
|
||||
*/
|
||||
private void seedFeeSheetsIfEmpty() {
|
||||
Long n = em.createQuery("select count(f) from HomesBranchFee f where f.groupId='demo'", Long.class)
|
||||
.getSingleResult();
|
||||
if (n != null && n > 0) return;
|
||||
|
||||
LocalDateTime start = LocalDateTime.of(2024, 1, 1, 0, 0);
|
||||
String[][] branchFees = {
|
||||
{"alone", "강남점", "본사그룹", "KT"},
|
||||
{"alone", "서초점", "본사그룹", "KT"},
|
||||
{"alone", "서초점", "본사그룹", "SKB"},
|
||||
{"alone", "서초점", "본사그룹", "LGU+"},
|
||||
{"alone", "송파점", "동부그룹", "KT"},
|
||||
{"alone", "송파점", "동부그룹", "SKB"},
|
||||
{"group", "", "본사그룹", "KT"},
|
||||
{"group", "", "동부그룹", "SKB"},
|
||||
};
|
||||
Object[][] products = {
|
||||
{"e01", "기가인터넷 1G", 100000},
|
||||
{"e01", "안심 인터넷", 80000},
|
||||
{"e01", "B tv 인터넷", 90000},
|
||||
{"e01", "U+ 인터넷", 95000},
|
||||
{"e03", "B tv 프리미엄", 50000},
|
||||
{"e02", "집전화 스마트", 20000},
|
||||
};
|
||||
|
||||
for (String[] bf : branchFees) {
|
||||
HomesBranchFee fee = new HomesBranchFee();
|
||||
fee.setGroupId("demo");
|
||||
fee.setFeeType(bf[0]);
|
||||
fee.setBranchName(blank(bf[1]) ? null : bf[1]);
|
||||
fee.setBranchGroupName(blank(bf[2]) ? null : bf[2]);
|
||||
fee.setCarrier(bf[3]);
|
||||
fee.setStartDate(start);
|
||||
fee.setMemo("샘플 수수료 (" + bf[0] + "/" + bf[3] + ")");
|
||||
em.persist(fee);
|
||||
em.flush();
|
||||
for (Object[] p : products) {
|
||||
if (!String.valueOf(p[1]).contains(bf[3].substring(0, 1))
|
||||
&& !"KT".equals(bf[3]) && !"e01".equals(p[0])) {
|
||||
// keep most products for all carriers
|
||||
}
|
||||
HomesBranchFeeProduct row = new HomesBranchFeeProduct();
|
||||
row.setGroupId("demo");
|
||||
row.setFeeId(fee.getId());
|
||||
row.setProductType(String.valueOf(p[0]));
|
||||
row.setProductName(String.valueOf(p[1]));
|
||||
row.setFee((Integer) p[2]);
|
||||
em.persist(row);
|
||||
}
|
||||
}
|
||||
|
||||
String[] centers = {"박처리", "최처리", "이처리"};
|
||||
String[] carriers = {"KT", "SKB", "LGU+"};
|
||||
for (String center : centers) {
|
||||
for (String carrier : carriers) {
|
||||
// 최처리+LGU+ 는 의도적으로 제외 → 없음 알림 테스트
|
||||
if ("최처리".equals(center) && "LGU+".equals(carrier)) continue;
|
||||
HomesCenterFee fee = new HomesCenterFee();
|
||||
fee.setGroupId("demo");
|
||||
fee.setCenterName(center);
|
||||
fee.setCarrier(carrier);
|
||||
fee.setStartDate(start);
|
||||
fee.setMemo("샘플 입력처 수수료");
|
||||
em.persist(fee);
|
||||
em.flush();
|
||||
for (Object[] p : products) {
|
||||
HomesCenterFeeProduct row = new HomesCenterFeeProduct();
|
||||
row.setGroupId("demo");
|
||||
row.setFeeId(fee.getId());
|
||||
row.setProductType(String.valueOf(p[0]));
|
||||
row.setProductName(String.valueOf(p[1]));
|
||||
row.setFee(((Integer) p[2]) / 2);
|
||||
em.persist(row);
|
||||
}
|
||||
}
|
||||
}
|
||||
em.flush();
|
||||
}
|
||||
|
||||
/** 영업점관리 목록 — 정산/수수료 샘플과 동일 영업점명 */
|
||||
private void seedBranchesIfEmpty() {
|
||||
Long n = em.createQuery("select count(b) from HomesBranch b where b.groupId='demo'", Long.class)
|
||||
.getSingleResult();
|
||||
if (n != null && n > 0) return;
|
||||
|
||||
HomesBranchGroup g1 = new HomesBranchGroup();
|
||||
g1.setGroupId("demo");
|
||||
g1.setName("본사그룹");
|
||||
g1.setSeq(1);
|
||||
em.persist(g1);
|
||||
HomesBranchGroup g2 = new HomesBranchGroup();
|
||||
g2.setGroupId("demo");
|
||||
g2.setName("동부그룹");
|
||||
g2.setSeq(2);
|
||||
em.persist(g2);
|
||||
em.flush();
|
||||
|
||||
Object[][] rows = {
|
||||
// officeName, name, userid, sido, groupId, level, grant, phone, isTax, hasBiz, email
|
||||
{"강남점", "강남대표", "gangnam", "서울", g1.getId(), 1, 1, "010-1111-1001", 1, true, "gangnam@demo.local"},
|
||||
{"서초점", "서초대표", "seocho", "서울", g1.getId(), 1, 1, "010-2222-1002", 1, false, "seocho@demo.local"},
|
||||
{"송파점", "송파대표", "songpa", "서울", g2.getId(), 1, 1, "010-3333-1003", 0, true, "songpa@demo.local"},
|
||||
{"딜러스", "딜러스", "dealers", "대구", g2.getId(), 1, 1, "010-4444-2001", 1, false, "dealers@demo.local"},
|
||||
{"미승인점", "대기초기", "pending1", "부산", g1.getId(), 1, 0, "010-5555-3001", 1, false, "pending@demo.local"},
|
||||
{"박처리", "박처리", "center_park", "서울", null, 5, 1, "010-6666-4001", 1, false, "park@demo.local"},
|
||||
};
|
||||
for (Object[] r : rows) {
|
||||
HomesBranch b = new HomesBranch();
|
||||
b.setGroupId("demo");
|
||||
b.setOfficeName(String.valueOf(r[0]));
|
||||
b.setName(String.valueOf(r[1]));
|
||||
b.setUserid(String.valueOf(r[2]));
|
||||
b.setPasswordHash(encoder.encode("demo1234"));
|
||||
b.setSido(String.valueOf(r[3]));
|
||||
b.setBranchGroupId(r[4] == null ? null : (Long) r[4]);
|
||||
b.setLevel((Integer) r[5]);
|
||||
b.setGrantFlag((Integer) r[6]);
|
||||
b.setPhone(String.valueOf(r[7]));
|
||||
b.setTel("02-000-" + String.valueOf(r[2]).substring(0, Math.min(4, String.valueOf(r[2]).length())));
|
||||
b.setIsTax((Integer) r[8]);
|
||||
b.setHasBusinessLicense((Boolean) r[9]);
|
||||
b.setEmail(String.valueOf(r[10]));
|
||||
b.setAddress(b.getSido() + " " + b.getOfficeName() + " 1길 1");
|
||||
b.setZipcode("06234");
|
||||
b.setAccountBank("국민은행");
|
||||
b.setAccountNumber("123-456-7890");
|
||||
b.setAccountName(b.getName());
|
||||
if (Integer.valueOf(5).equals(b.getLevel())) {
|
||||
b.setCenterName(b.getOfficeName());
|
||||
}
|
||||
if (b.isHasBusinessLicense()) {
|
||||
b.setLicenseNumber("123-45-67890");
|
||||
b.setLicenseType("도소매");
|
||||
b.setLicenseItem("통신판매");
|
||||
}
|
||||
em.persist(b);
|
||||
}
|
||||
em.flush();
|
||||
}
|
||||
|
||||
private static boolean blank(String s) { return s == null || s.isBlank(); }
|
||||
}
|
||||
|
||||
@@ -456,6 +456,8 @@ class HomesReception extends BaseEntity {
|
||||
String region;
|
||||
/** 진행상황 */
|
||||
String progressStatus;
|
||||
/** 직전 진행상태 (가정산 취소 복원용) */
|
||||
String prevProgressStatus;
|
||||
/** 주문번호 */
|
||||
String orderNumber;
|
||||
/** 연결된 ISP id */
|
||||
@@ -535,6 +537,92 @@ class HomesReception extends BaseEntity {
|
||||
String adminFile1;
|
||||
String adminFile2;
|
||||
String adminFile3;
|
||||
/** 영업점 정산 cost FK (가정산→정산대기 시 연결) */
|
||||
Long receptionCostId;
|
||||
/** 입력처 정산 cost FK */
|
||||
Long receptionCenterCostId;
|
||||
}
|
||||
|
||||
/** 접수별 영업점 정산 비용 — billing order_costs */
|
||||
@Entity @Table(name = "homes_reception_costs") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
|
||||
class HomesReceptionCost extends BaseEntity {
|
||||
String type;
|
||||
String typeDesc;
|
||||
Integer cost = 0;
|
||||
Integer eventAmt = 0;
|
||||
Integer agreementAmt = 0;
|
||||
Integer localAmt = 0;
|
||||
Integer giftAmt = 0;
|
||||
Integer vocAmt = 0;
|
||||
Integer etcAmt = 0;
|
||||
Integer total = 0;
|
||||
@Column(columnDefinition = "TEXT") String progressMemo;
|
||||
@Column(columnDefinition = "TEXT") String memo;
|
||||
Long receptionId;
|
||||
Long costGroupId;
|
||||
}
|
||||
|
||||
/** 영업점 정산 그룹 — billing order_cost_groups */
|
||||
@Entity @Table(name = "homes_reception_cost_groups") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
|
||||
class HomesReceptionCostGroup extends BaseEntity {
|
||||
String branchName;
|
||||
Integer plusAmt = 0;
|
||||
Integer minusAmt = 0;
|
||||
Integer total = 0;
|
||||
/** 세율(%) — 세금계산서 10 / 종합소득 -3.3 */
|
||||
java.math.BigDecimal rate = java.math.BigDecimal.ZERO;
|
||||
/** 1=세금계산서발행, 0=종합소득세공제 */
|
||||
Integer isTax = 1;
|
||||
LocalDateTime taxDate;
|
||||
LocalDateTime completedAt;
|
||||
LocalDateTime transferredAt;
|
||||
String bank;
|
||||
String accountNumber;
|
||||
String accountName;
|
||||
@Column(columnDefinition = "TEXT") String memo;
|
||||
String file1;
|
||||
String file2;
|
||||
String file3;
|
||||
}
|
||||
|
||||
/** 접수별 입력처 정산 비용 — billing order_center_costs */
|
||||
@Entity @Table(name = "homes_reception_center_costs") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
|
||||
class HomesReceptionCenterCost extends BaseEntity {
|
||||
String type;
|
||||
String typeDesc;
|
||||
Integer cost = 0;
|
||||
Integer eventAmt = 0;
|
||||
Integer agreementAmt = 0;
|
||||
Integer localAmt = 0;
|
||||
Integer giftAmt = 0;
|
||||
Integer vocAmt = 0;
|
||||
Integer etcAmt = 0;
|
||||
Integer total = 0;
|
||||
@Column(columnDefinition = "TEXT") String progressMemo;
|
||||
@Column(columnDefinition = "TEXT") String memo;
|
||||
Long receptionId;
|
||||
Long costGroupId;
|
||||
}
|
||||
|
||||
/** 입력처 정산 그룹 — billing order_center_cost_groups */
|
||||
@Entity @Table(name = "homes_reception_center_cost_groups") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
|
||||
class HomesReceptionCenterCostGroup extends BaseEntity {
|
||||
String centerName;
|
||||
Integer plusAmt = 0;
|
||||
Integer minusAmt = 0;
|
||||
Integer total = 0;
|
||||
java.math.BigDecimal rate = java.math.BigDecimal.ZERO;
|
||||
Integer isTax = 1;
|
||||
LocalDateTime taxDate;
|
||||
LocalDateTime completedAt;
|
||||
LocalDateTime transferredAt;
|
||||
String bank;
|
||||
String accountNumber;
|
||||
String accountName;
|
||||
@Column(columnDefinition = "TEXT") String memo;
|
||||
String file1;
|
||||
String file2;
|
||||
String file3;
|
||||
}
|
||||
|
||||
/** 접수 진행 이력 */
|
||||
@@ -585,3 +673,168 @@ class HomesReceptionIspAgreement extends BaseEntity {
|
||||
String name;
|
||||
Boolean active;
|
||||
}
|
||||
|
||||
/** 영업점 그룹 — billing branch_groups */
|
||||
@Entity @Table(name = "homes_branch_groups") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
|
||||
class HomesBranchGroup extends BaseEntity {
|
||||
String name;
|
||||
Integer seq = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 영업점 마스터 — billing Branch + 상세(계좌/사업자/권한 등)를 한 테이블로 축소.
|
||||
* officeName 은 접수·수수료의 branch / branchName 문자열과 맞춤.
|
||||
*/
|
||||
@Entity @Table(name = "homes_branches") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
|
||||
class HomesBranch extends BaseEntity {
|
||||
String name;
|
||||
String officeName;
|
||||
String userid;
|
||||
String passwordHash;
|
||||
String tel;
|
||||
String phone;
|
||||
String fax;
|
||||
String email;
|
||||
String zipcode;
|
||||
String address;
|
||||
String addressDetail;
|
||||
/** 지역(시/도) */
|
||||
String sido;
|
||||
/** 1=영업점, 5=센터, 9=관리자, 10=최고관리자 */
|
||||
Integer level = 1;
|
||||
/** 1=승인, 0=미승인(거부) */
|
||||
Integer grantFlag = 0;
|
||||
Long branchGroupId;
|
||||
/** 레벨=센터일 때 입력처명 */
|
||||
String centerName;
|
||||
boolean hasBusinessLicense = false;
|
||||
Integer isTax = 1;
|
||||
String accountBank;
|
||||
String accountNumber;
|
||||
String accountName;
|
||||
String contactPhone;
|
||||
String contactNateon;
|
||||
Integer acceptPhone = 0;
|
||||
Integer acceptNateon = 0;
|
||||
@Column(columnDefinition = "TEXT") String memo;
|
||||
@Column(columnDefinition = "TEXT") String adminMemo;
|
||||
String licenseNumber;
|
||||
String licenseType;
|
||||
String licenseItem;
|
||||
/** JSON 배열 문자열, 최대 6개 */
|
||||
@Column(columnDefinition = "TEXT") String notifyPhonesJson;
|
||||
boolean permissionOrderDateUpdate = false;
|
||||
boolean permissionCalculate = false;
|
||||
boolean permissionConfig = false;
|
||||
boolean permissionExcelDownload = false;
|
||||
boolean permissionChart = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 영업점 수수료 시트 — billing branch_fees / branch_group_fees 축소.
|
||||
* BillCare는 영업점명을 문자열로 쓰므로 branchName 키.
|
||||
*/
|
||||
@Entity @Table(name = "homes_branch_fees") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
|
||||
class HomesBranchFee extends BaseEntity {
|
||||
/** alone=개별, group=그룹 */
|
||||
String feeType;
|
||||
String branchName;
|
||||
String branchGroupName;
|
||||
String carrier;
|
||||
LocalDateTime startDate;
|
||||
@Column(columnDefinition = "TEXT") String memo;
|
||||
/** 첨부 표시용 파일명 (업로드는 선택 구현) */
|
||||
String file1;
|
||||
String file2;
|
||||
String file3;
|
||||
String file4;
|
||||
String file5;
|
||||
}
|
||||
|
||||
@Entity @Table(name = "homes_branch_fee_products") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
|
||||
class HomesBranchFeeProduct extends BaseEntity {
|
||||
Long feeId;
|
||||
String productType;
|
||||
String productName;
|
||||
Integer fee = 0;
|
||||
}
|
||||
|
||||
/** 입력처 수수료 시트 — billing center_fees */
|
||||
@Entity @Table(name = "homes_center_fees") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
|
||||
class HomesCenterFee extends BaseEntity {
|
||||
String centerName;
|
||||
String carrier;
|
||||
LocalDateTime startDate;
|
||||
@Column(columnDefinition = "TEXT") String memo;
|
||||
}
|
||||
|
||||
@Entity @Table(name = "homes_center_fee_products") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
|
||||
class HomesCenterFeeProduct extends BaseEntity {
|
||||
Long feeId;
|
||||
String productType;
|
||||
String productName;
|
||||
Integer fee = 0;
|
||||
}
|
||||
|
||||
/** 통신사 그룹 */
|
||||
@Entity @Table(name="homes_isp_groups") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
|
||||
class HomesIspGroup extends BaseEntity {
|
||||
String name;
|
||||
Integer seq = 0;
|
||||
}
|
||||
|
||||
/** 통신사 인증링크 */
|
||||
@Entity @Table(name="homes_isp_links") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
|
||||
class HomesIspLink extends BaseEntity {
|
||||
Long ispId;
|
||||
String name;
|
||||
String url;
|
||||
Integer seq = 0;
|
||||
Boolean active = true;
|
||||
}
|
||||
|
||||
/** 입력처(센터) 마스터 — billing Center */
|
||||
@Entity @Table(name="homes_input_centers") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
|
||||
class HomesInputCenter extends BaseEntity {
|
||||
String name;
|
||||
String tel;
|
||||
String phone;
|
||||
String manager;
|
||||
Integer seq = 0;
|
||||
Boolean active = true;
|
||||
String accountBank;
|
||||
String accountNumber;
|
||||
String accountName;
|
||||
@Column(columnDefinition="TEXT") String memo;
|
||||
String file1; String file2; String file3; String file4; String file5;
|
||||
}
|
||||
|
||||
/** 게시판 정의 — billing Board (HomesBbs는 게시글) */
|
||||
@Entity @Table(name="homes_boards") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
|
||||
class HomesBoard extends BaseEntity {
|
||||
String name;
|
||||
Integer listLevel = 1;
|
||||
Integer readLevel = 1;
|
||||
Integer writeLevel = 9;
|
||||
Integer seq = 0;
|
||||
Boolean active = true;
|
||||
}
|
||||
|
||||
/** 퀵전송 입력처 인증 */
|
||||
@Entity @Table(name="homes_center_quick_auths") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
|
||||
class HomesCenterQuickAuth extends BaseEntity {
|
||||
Long centerId;
|
||||
String userid;
|
||||
String passwordEnc;
|
||||
String baseUrl;
|
||||
Boolean authorized = false;
|
||||
}
|
||||
|
||||
/** 퀵전송 진행알림 로그 */
|
||||
@Entity @Table(name="homes_quick_delivery_logs") @Getter @Setter @Builder @NoArgsConstructor @AllArgsConstructor
|
||||
class HomesQuickDeliveryLog extends BaseEntity {
|
||||
String name;
|
||||
String status;
|
||||
@Column(columnDefinition="TEXT") String memo;
|
||||
Long orderQuickDeliveryClientId;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
package com.bizcare.agent;
|
||||
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* billing_react AppConstant 의 접수/사은품/입력처/정산 상태·전이를 BillCare 에 이식.
|
||||
*/
|
||||
public final class ReceptionStatuses {
|
||||
private ReceptionStatuses() {}
|
||||
|
||||
public static final List<String> PRE_CALC_STATUSES =
|
||||
List.of("f19", "f20", "f21", "f22", "f28", "f23");
|
||||
public static final List<String> CENTER_PRE_CALC_STATUSES =
|
||||
List.of("h01", "h05", "h06", "h07", "h08", "h11");
|
||||
|
||||
public static final Map<String, String> PROGRESS = map(
|
||||
"f01", "접수", "f02", "재접수", "f03", "접수진행중", "f04", "접수완료",
|
||||
"f05", "승인대기중", "f06", "회신요청", "f07", "접수반송", "f08", "미개통오더반송",
|
||||
"f09", "TC실패반송", "f10", "접수보류", "f11", "접수취소", "f12", "개통중",
|
||||
"f13", "기사완료", "f14", "개통완료", "f15", "개통보류", "f16", "개통취소",
|
||||
"f17", "개통불가", "f18", "정산보류", "f19", "위면해지", "f20", "직권해지",
|
||||
"f21", "유보중단환수", "f22", "해지", "f23", "정산대기", "f24", "정산완료",
|
||||
"f25", "송금완료", "f26", "환수정산완료", "f27", "환수완료",
|
||||
"f28", "VOC(귀책)차감", "f29", "VOC(귀책)차감완료");
|
||||
|
||||
public static final Map<String, String> GIFT = map(
|
||||
"g01", "지급요청", "g02", "지급재요청", "g03", "반송", "g04", "처리중",
|
||||
"g05", "지급보류", "g06", "지급취소", "g07", "지급완료", "g08", "회수요청",
|
||||
"g09", "회수(반환)완료", "g10", "등록완료", "g11", "삭제완료", "g12", "입금증요청",
|
||||
"g13", "입금증완료", "g14", "입금증첨부완료", "g15", "입금증(센터)전달완료");
|
||||
|
||||
public static final Map<String, String> CENTER = map(
|
||||
"h01", "정산대기", "h02", "정산완료", "h03", "입금완료", "h04", "정산보류",
|
||||
"h05", "위면해지", "h06", "직권해지", "h07", "유보중단환수", "h08", "해지",
|
||||
"h09", "해지정산완료", "h10", "해지송금완료", "h11", "VOC(귀책)송금", "h12", "VOC(귀책)송금완료");
|
||||
|
||||
public static final Map<String, String> GIFT_PLACE = map(
|
||||
"b02", "자체발송", "b01", "본사발송", "b03", "본사(센터)", "b04", "본사(관리자)");
|
||||
|
||||
public static final Map<String, String> COST_TYPE = map(
|
||||
"i01", "정산", "i02", "위면해지", "i03", "직권해지", "i04", "유보중단환수", "i05", "해지", "i06", "voc차감");
|
||||
|
||||
public static final Map<String, String> CENTER_COST_TYPE = map(
|
||||
"k01", "정산", "k02", "위면해지", "k03", "직권해지", "k04", "유보중단환수", "k05", "해지", "k06", "voc차감");
|
||||
|
||||
/** 구 BillCare 한글 라벨 → 코드 */
|
||||
public static final Map<String, String> LEGACY_PROGRESS = Map.ofEntries(
|
||||
Map.entry("1.접수완료", "f04"),
|
||||
Map.entry("2.고객통화", "f03"),
|
||||
Map.entry("3.서류검토", "f05"),
|
||||
Map.entry("4.개통완료", "f14"),
|
||||
Map.entry("5.사은품지급", "f14"),
|
||||
Map.entry("6.정산완료", "f24"),
|
||||
Map.entry("9.취소", "f11"),
|
||||
Map.entry("접수", "f01"),
|
||||
Map.entry("접수완료", "f04"),
|
||||
Map.entry("개통완료", "f14"),
|
||||
Map.entry("정산대기", "f23"),
|
||||
Map.entry("정산완료", "f24"),
|
||||
Map.entry("송금완료", "f25"),
|
||||
Map.entry("접수취소", "f11")
|
||||
);
|
||||
|
||||
public static final Map<String, String> LEGACY_GIFT = Map.of(
|
||||
"미지급", "",
|
||||
"지급대기", "g01",
|
||||
"지급요청", "g01",
|
||||
"지급완료", "g07",
|
||||
"반송", "g03"
|
||||
);
|
||||
|
||||
public static final Map<String, String> LEGACY_CENTER = Map.of(
|
||||
"정산대기", "h01",
|
||||
"정산완료", "h02",
|
||||
"입금완료", "h03",
|
||||
"회수대기", "h05",
|
||||
"회수완료", "h09",
|
||||
"미지정", "h01"
|
||||
);
|
||||
|
||||
public static List<String> progressCodes() {
|
||||
return new ArrayList<>(PROGRESS.keySet());
|
||||
}
|
||||
|
||||
public static List<String> giftCodes() {
|
||||
return new ArrayList<>(GIFT.keySet());
|
||||
}
|
||||
|
||||
public static List<String> centerCodes() {
|
||||
return new ArrayList<>(CENTER.keySet());
|
||||
}
|
||||
|
||||
public static String progressLabel(String code) {
|
||||
if (code == null || code.isBlank()) return "";
|
||||
return PROGRESS.getOrDefault(code, code);
|
||||
}
|
||||
|
||||
public static String giftLabel(String code) {
|
||||
if (code == null || code.isBlank()) return "";
|
||||
return GIFT.getOrDefault(code, code);
|
||||
}
|
||||
|
||||
public static String centerLabel(String code) {
|
||||
if (code == null || code.isBlank()) return "";
|
||||
return CENTER.getOrDefault(code, code);
|
||||
}
|
||||
|
||||
public static String normalizeProgress(String raw) {
|
||||
if (raw == null || raw.isBlank()) return "";
|
||||
if (PROGRESS.containsKey(raw)) return raw;
|
||||
return LEGACY_PROGRESS.getOrDefault(raw, raw);
|
||||
}
|
||||
|
||||
public static String normalizeGift(String raw) {
|
||||
if (raw == null) return "";
|
||||
if (raw.isBlank() || "미지급".equals(raw)) return "";
|
||||
if (GIFT.containsKey(raw)) return raw;
|
||||
return LEGACY_GIFT.getOrDefault(raw, raw);
|
||||
}
|
||||
|
||||
public static String normalizeCenter(String raw) {
|
||||
if (raw == null || raw.isBlank()) return "";
|
||||
if (CENTER.containsKey(raw)) return raw;
|
||||
return LEGACY_CENTER.getOrDefault(raw, raw);
|
||||
}
|
||||
|
||||
public static Set<String> orderStatusTransitions(String current) {
|
||||
if (current == null) return Set.of();
|
||||
return switch (current) {
|
||||
case "f01", "f02", "f03", "f04", "f05", "f06", "f07", "f08", "f09", "f10", "f11", "f12",
|
||||
"f13", "f15", "f16", "f17" ->
|
||||
set("f01", "f02", "f03", "f04", "f05", "f06", "f07", "f08", "f09",
|
||||
"f10", "f11", "f12", "f13", "f14", "f15", "f16", "f17", "f18");
|
||||
case "f18", "f14" ->
|
||||
set("f01", "f02", "f03", "f04", "f05", "f06", "f07", "f08", "f09",
|
||||
"f10", "f11", "f12", "f13", "f14", "f15", "f16", "f17", "f18",
|
||||
"f19", "f20", "f21", "f22", "f23");
|
||||
case "f23" -> set("f23");
|
||||
case "f24" -> set("f24");
|
||||
case "f25" -> set("f25", "f19", "f20", "f21", "f22", "f28");
|
||||
case "f19" -> set("f19");
|
||||
case "f20" -> set("f20");
|
||||
case "f21" -> set("f21");
|
||||
case "f22" -> set("f22");
|
||||
case "f28" -> set("f28");
|
||||
case "f29" -> set("f28", "f29", "f19", "f20", "f21", "f22");
|
||||
case "f26" -> set("f26");
|
||||
case "f27" -> set("f27");
|
||||
default -> Set.of();
|
||||
};
|
||||
}
|
||||
|
||||
public static Set<String> centerStatusTransitions(String current) {
|
||||
if (current == null || current.isBlank()) return set("h01");
|
||||
return switch (current) {
|
||||
case "h01" -> set("h01");
|
||||
case "h02" -> set("h02");
|
||||
case "h03" -> set("h03", "h05", "h06", "h07", "h08", "h11");
|
||||
case "h04" -> set("h04", "h01");
|
||||
case "h05" -> set("h05");
|
||||
case "h06" -> set("h06");
|
||||
case "h07" -> set("h07");
|
||||
case "h08" -> set("h08");
|
||||
case "h09" -> set("h09");
|
||||
case "h10" -> set("h10");
|
||||
case "h11" -> set("h11");
|
||||
case "h12" -> set("h05", "h06", "h07", "h08", "h11");
|
||||
default -> Set.of();
|
||||
};
|
||||
}
|
||||
|
||||
public static String orderCostType(String orderStatus) {
|
||||
if (orderStatus == null) return "";
|
||||
return switch (orderStatus) {
|
||||
case "f19" -> "i02";
|
||||
case "f20" -> "i03";
|
||||
case "f21" -> "i04";
|
||||
case "f22" -> "i05";
|
||||
case "f28" -> "i06";
|
||||
case "f23" -> "i01";
|
||||
default -> "";
|
||||
};
|
||||
}
|
||||
|
||||
public static String centerCostType(String status) {
|
||||
if (status == null) return "";
|
||||
return switch (status) {
|
||||
case "h05" -> "k02";
|
||||
case "h06" -> "k03";
|
||||
case "h07" -> "k04";
|
||||
case "h08" -> "k05";
|
||||
case "h11" -> "k06";
|
||||
case "h01" -> "k01";
|
||||
default -> "";
|
||||
};
|
||||
}
|
||||
|
||||
public static boolean isPreCalc(String status) {
|
||||
return status != null && PRE_CALC_STATUSES.contains(status);
|
||||
}
|
||||
|
||||
public static boolean isCenterPreCalc(String status) {
|
||||
return status != null && CENTER_PRE_CALC_STATUSES.contains(status);
|
||||
}
|
||||
|
||||
private static Map<String, String> map(String... kv) {
|
||||
Map<String, String> m = new LinkedHashMap<>();
|
||||
for (int i = 0; i < kv.length; i += 2) m.put(kv[i], kv[i + 1]);
|
||||
return Collections.unmodifiableMap(m);
|
||||
}
|
||||
|
||||
private static Set<String> set(String... v) {
|
||||
return new LinkedHashSet<>(Arrays.asList(v));
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ import ContractAdjustPage from './pages/care/ContractAdjustPage'
|
||||
import ContractBalancePage from './pages/care/ContractBalancePage'
|
||||
import ContractWritePage from './pages/care/ContractWritePage'
|
||||
import ReceptionListPage from './pages/care/ReceptionListPage'
|
||||
import BranchListPage from './pages/care/BranchListPage'
|
||||
import CalculatePage from './pages/care/CalculatePage'
|
||||
import InvoicePublishPage from './pages/care/InvoicePublishPage'
|
||||
import InvoiceMyPage from './pages/care/InvoiceMyPage'
|
||||
import InvoiceLogPage from './pages/care/InvoiceLogPage'
|
||||
@@ -48,6 +50,14 @@ import SettingPreferencePage from './pages/care/SettingPreferencePage'
|
||||
import SettingLevelPage from './pages/care/SettingLevelPage'
|
||||
import SettingPartner1Page from './pages/care/SettingPartner1Page'
|
||||
import SettingPartner2Page from './pages/care/SettingPartner2Page'
|
||||
import SettingIspPage from './pages/care/SettingIspPage'
|
||||
import SettingCenterPage from './pages/care/SettingCenterPage'
|
||||
import SettingIspLinkPage from './pages/care/SettingIspLinkPage'
|
||||
import SettingBoardAdminPage from './pages/care/SettingBoardAdminPage'
|
||||
import SettingFeePage from './pages/care/SettingFeePage'
|
||||
import SettingQuickDeliveryPage from './pages/care/SettingQuickDeliveryPage'
|
||||
import SettingConfigurePage from './pages/care/SettingConfigurePage'
|
||||
import SettingUsageReportPage from './pages/care/SettingUsageReportPage'
|
||||
import CsNoticePage from './pages/care/CsNoticePage'
|
||||
import CsQuestionPage from './pages/care/CsQuestionPage'
|
||||
import BbsPage from './pages/care/BbsPage'
|
||||
@@ -70,6 +80,8 @@ export default function App() {
|
||||
token: {
|
||||
colorPrimary: THEME_PRIMARY[theme] || '#1677ff',
|
||||
colorBgContainer: theme === 'light' ? '#ffffff' : theme === 'blue' ? '#15273d' : '#24283b',
|
||||
colorBgElevated: theme === 'light' ? '#ffffff' : theme === 'blue' ? '#15273d' : '#24283b',
|
||||
colorBgLayout: theme === 'light' ? '#f1f5f9' : theme === 'blue' ? '#0a1522' : '#16181e',
|
||||
colorTextLightSolid: '#ffffff',
|
||||
colorBorder: theme === 'light' ? undefined : theme === 'blue' ? 'rgba(143, 187, 224, 0.55)' : 'rgba(192, 202, 245, 0.5)',
|
||||
colorBorderSecondary: theme === 'light' ? undefined : theme === 'blue' ? 'rgba(143, 187, 224, 0.35)' : 'rgba(192, 202, 245, 0.32)',
|
||||
@@ -80,6 +92,9 @@ export default function App() {
|
||||
defaultShadow: 'none',
|
||||
dangerShadow: 'none',
|
||||
},
|
||||
Drawer: {
|
||||
colorBgElevated: theme === 'light' ? '#ffffff' : theme === 'blue' ? '#15273d' : '#24283b',
|
||||
},
|
||||
Checkbox: {
|
||||
colorBorder: theme === 'light' ? undefined : theme === 'blue' ? 'rgba(143, 187, 224, 0.65)' : 'rgba(192, 202, 245, 0.58)',
|
||||
},
|
||||
@@ -114,6 +129,11 @@ export default function App() {
|
||||
|
||||
<Route path="reception/list" element={<ReceptionListPage />} />
|
||||
|
||||
<Route path="branch/list" element={<BranchListPage />} />
|
||||
|
||||
<Route path="calculate/branch" element={<CalculatePage kind="branch" />} />
|
||||
<Route path="calculate/center" element={<CalculatePage kind="center" />} />
|
||||
|
||||
<Route path="invoice/publish" element={<InvoicePublishPage />} />
|
||||
<Route path="invoice/my" element={<InvoiceMyPage />} />
|
||||
<Route path="invoice/log" element={<InvoiceLogPage />} />
|
||||
@@ -145,6 +165,14 @@ export default function App() {
|
||||
<Route path="setting/level" element={<SettingLevelPage />} />
|
||||
<Route path="setting/partner1" element={<SettingPartner1Page />} />
|
||||
<Route path="setting/partner2" element={<SettingPartner2Page />} />
|
||||
<Route path="setting/isp" element={<SettingIspPage />} />
|
||||
<Route path="setting/center" element={<SettingCenterPage />} />
|
||||
<Route path="setting/isp-link" element={<SettingIspLinkPage />} />
|
||||
<Route path="setting/board-admin" element={<SettingBoardAdminPage />} />
|
||||
<Route path="setting/fee" element={<SettingFeePage />} />
|
||||
<Route path="setting/quick-delivery" element={<SettingQuickDeliveryPage />} />
|
||||
<Route path="setting/configure" element={<SettingConfigurePage />} />
|
||||
<Route path="setting/usage" element={<SettingUsageReportPage />} />
|
||||
|
||||
<Route path="cs/notice" element={<CsNoticePage />} />
|
||||
<Route path="cs/question" element={<CsQuestionPage />} />
|
||||
|
||||
@@ -75,7 +75,7 @@ export default function AgentShell({ shop = false }: Props) {
|
||||
() => (shop ? allSections : filterMenuSections(allSections, allowedMenus)),
|
||||
[shop, allSections, allowedMenus],
|
||||
)
|
||||
const selectedKey = resolveSelectedKey(location.pathname)
|
||||
const selectedKey = resolveSelectedKey(location.pathname, location.search)
|
||||
|
||||
const [openCards, setOpenCards] = useState<Record<string, boolean>>({})
|
||||
|
||||
@@ -91,7 +91,7 @@ export default function AgentShell({ shop = false }: Props) {
|
||||
})
|
||||
return next
|
||||
})
|
||||
}, [location.pathname, sections])
|
||||
}, [location.pathname, location.search, sections])
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem('lp.width', String(panelWidth))
|
||||
@@ -220,7 +220,7 @@ export default function AgentShell({ shop = false }: Props) {
|
||||
key={it.key}
|
||||
to={it.key}
|
||||
title={it.label}
|
||||
className={({ isActive }) => (isActive || selectedKey === it.key ? 'active' : undefined)}
|
||||
className={() => (selectedKey === it.key ? 'active' : undefined)}
|
||||
>
|
||||
{it.label}
|
||||
</NavLink>
|
||||
|
||||
@@ -207,7 +207,7 @@ function DraggableModal({
|
||||
titleActions={titleActions}
|
||||
draggable={draggable}
|
||||
showClose={showClose}
|
||||
onClose={(e) => onCancel?.(e)}
|
||||
onClose={(e) => handleCancel(e)}
|
||||
onMouseDown={onTitleMouseDown}
|
||||
/>
|
||||
)}
|
||||
@@ -227,37 +227,15 @@ function DraggableModal({
|
||||
)
|
||||
}
|
||||
|
||||
function StaticDragShell({
|
||||
function StaticDragWrap({
|
||||
title,
|
||||
children,
|
||||
onTitleMouseDown,
|
||||
offset,
|
||||
onClose,
|
||||
}: {
|
||||
title?: ReactNode
|
||||
children: ReactNode
|
||||
onTitleMouseDown: (e: ReactMouseEvent) => void
|
||||
offset: { x: number; y: number }
|
||||
onClose?: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="app-modal-drag-root"
|
||||
style={{ transform: `translate(${offset.x}px, ${offset.y}px)` }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="app-modal-static-panel">
|
||||
<TitleBar
|
||||
title={title}
|
||||
draggable
|
||||
showClose={false}
|
||||
onMouseDown={onTitleMouseDown}
|
||||
/>
|
||||
<div className="app-modal-slot">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StaticDragWrap({ title, children }: { title?: ReactNode; children: ReactNode }) {
|
||||
const [open] = useState(true)
|
||||
const { offset, onTitleMouseDown, shouldSuppressClose } = useDraggableOffset(open)
|
||||
|
||||
@@ -273,13 +251,61 @@ function StaticDragWrap({ title, children }: { title?: ReactNode; children: Reac
|
||||
}, [shouldSuppressClose])
|
||||
|
||||
return (
|
||||
<StaticDragShell title={title} offset={offset} onTitleMouseDown={onTitleMouseDown}>
|
||||
<StaticDragShell
|
||||
title={title}
|
||||
offset={offset}
|
||||
onTitleMouseDown={onTitleMouseDown}
|
||||
onClose={() => {
|
||||
if (onClose) onClose()
|
||||
else AntModal.destroyAll()
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</StaticDragShell>
|
||||
)
|
||||
}
|
||||
|
||||
function wrapStatic(config: ModalFuncProps = {}): ModalFuncProps {
|
||||
function StaticDragShell({
|
||||
title,
|
||||
children,
|
||||
onTitleMouseDown,
|
||||
offset,
|
||||
onClose,
|
||||
}: {
|
||||
title?: ReactNode
|
||||
children: ReactNode
|
||||
onTitleMouseDown: (e: ReactMouseEvent) => void
|
||||
offset: { x: number; y: number }
|
||||
onClose?: () => void
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className="app-modal-drag-root"
|
||||
style={{ transform: `translate(${offset.x}px, ${offset.y}px)` }}
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="app-modal-static-panel">
|
||||
<TitleBar
|
||||
title={title}
|
||||
draggable
|
||||
showClose={!!onClose}
|
||||
onClose={(e) => {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
onClose?.()
|
||||
}}
|
||||
onMouseDown={onTitleMouseDown}
|
||||
/>
|
||||
<div className="app-modal-slot">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function wrapStatic(
|
||||
config: ModalFuncProps = {},
|
||||
closer?: { current: (() => void) | null },
|
||||
): ModalFuncProps {
|
||||
const userRender = config.modalRender
|
||||
const title = config.title
|
||||
return {
|
||||
@@ -290,18 +316,37 @@ function wrapStatic(config: ModalFuncProps = {}): ModalFuncProps {
|
||||
className: withModalClass(config.className),
|
||||
rootClassName: ['app-draggable-modal-root', config.rootClassName].filter(Boolean).join(' '),
|
||||
modalRender: (node) => {
|
||||
const wrapped = <StaticDragWrap title={title}>{node}</StaticDragWrap>
|
||||
const wrapped = (
|
||||
<StaticDragWrap
|
||||
title={title}
|
||||
onClose={() => {
|
||||
closer?.current?.()
|
||||
if (!closer?.current) AntModal.destroyAll()
|
||||
}}
|
||||
>
|
||||
{node}
|
||||
</StaticDragWrap>
|
||||
)
|
||||
return userRender ? userRender(wrapped) : wrapped
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
DraggableModal.confirm = (props?: ModalFuncProps) => AntModal.confirm(wrapStatic(props))
|
||||
DraggableModal.info = (props?: ModalFuncProps) => AntModal.info(wrapStatic(props))
|
||||
DraggableModal.success = (props?: ModalFuncProps) => AntModal.success(wrapStatic(props))
|
||||
DraggableModal.error = (props?: ModalFuncProps) => AntModal.error(wrapStatic(props))
|
||||
DraggableModal.warning = (props?: ModalFuncProps) => AntModal.warning(wrapStatic(props))
|
||||
DraggableModal.warn = (props?: ModalFuncProps) => AntModal.warn(wrapStatic(props))
|
||||
function withStaticCloser(factory: (cfg: ModalFuncProps) => ReturnType<typeof AntModal.info>) {
|
||||
return (props?: ModalFuncProps) => {
|
||||
const closer: { current: (() => void) | null } = { current: null }
|
||||
const api = factory(wrapStatic(props, closer))
|
||||
closer.current = () => api.destroy()
|
||||
return api
|
||||
}
|
||||
}
|
||||
|
||||
DraggableModal.confirm = withStaticCloser((cfg) => AntModal.confirm(cfg))
|
||||
DraggableModal.info = withStaticCloser((cfg) => AntModal.info(cfg))
|
||||
DraggableModal.success = withStaticCloser((cfg) => AntModal.success(cfg))
|
||||
DraggableModal.error = withStaticCloser((cfg) => AntModal.error(cfg))
|
||||
DraggableModal.warning = withStaticCloser((cfg) => AntModal.warning(cfg))
|
||||
DraggableModal.warn = withStaticCloser((cfg) => AntModal.warn(cfg))
|
||||
DraggableModal.destroyAll = AntModal.destroyAll
|
||||
DraggableModal.useModal = AntModal.useModal
|
||||
DraggableModal.config = AntModal.config
|
||||
|
||||
@@ -5,6 +5,7 @@ import Modal from './DraggableModal'
|
||||
import { FormField, FormGrid, FormSection } from './OrderFormLayout'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../api/client'
|
||||
import { options } from '../pages/care/homesShared'
|
||||
import { statusOptions, PROGRESS_STATUS, GIFT_STATUS, GIFT_PLACE } from '../pages/care/receptionStatuses'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
@@ -16,12 +17,7 @@ type Props = {
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const GIFT_PLACES = [
|
||||
{ value: 'b01', label: '본사요청' },
|
||||
{ value: 'b02', label: '자체발송' },
|
||||
{ value: '본사', label: '본사발송' },
|
||||
{ value: '영업점', label: '영업점발송' },
|
||||
]
|
||||
const GIFT_PLACES = Object.entries(GIFT_PLACE).map(([value, label]) => ({ value, label }))
|
||||
|
||||
export default function ReceptionBulkModal({
|
||||
open,
|
||||
@@ -176,7 +172,9 @@ export default function ReceptionBulkModal({
|
||||
placeholder="진행상황 선택"
|
||||
value={pgStatus}
|
||||
onChange={(v) => setPgStatus(v)}
|
||||
options={options(progressStatuses)}
|
||||
options={statusOptions(
|
||||
Object.fromEntries((progressStatuses.length ? progressStatuses : Object.keys(PROGRESS_STATUS)).map((c) => [c, PROGRESS_STATUS[c] || c])),
|
||||
)}
|
||||
style={{ flex: 1, minWidth: 180 }}
|
||||
/>
|
||||
<Checkbox checked={pgNotNotice} onChange={(e) => setPgNotNotice(e.target.checked)}>
|
||||
@@ -236,7 +234,9 @@ export default function ReceptionBulkModal({
|
||||
placeholder="사은품 진행상황 선택"
|
||||
value={gStatus}
|
||||
onChange={(v) => setGStatus(v)}
|
||||
options={options(giftStatuses)}
|
||||
options={statusOptions(
|
||||
Object.fromEntries((giftStatuses.length ? giftStatuses : Object.keys(GIFT_STATUS)).map((c) => [c, GIFT_STATUS[c] || c])),
|
||||
)}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="사은품진행메모(LOG)" full>
|
||||
|
||||
@@ -14,6 +14,7 @@ import Modal from './DraggableModal'
|
||||
import PrgSection from './PrgSection'
|
||||
import ReceptionProgressReadOnly from './ReceptionProgressReadOnly'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../api/client'
|
||||
import { progressLabel, giftLabel, statusOptions, PROGRESS_STATUS, GIFT_STATUS, GIFT_PLACE } from '../pages/care/receptionStatuses'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
@@ -117,8 +118,13 @@ export default function ReceptionProgressModal({
|
||||
|
||||
const data = detailQuery.data
|
||||
const reception = data?.reception || data
|
||||
const progressStatuses: string[] = data?.progressStatuses || []
|
||||
const giftStatuses: string[] = data?.giftStatuses || []
|
||||
const progressStatuses: string[] = data?.progressStatuses || Object.keys(PROGRESS_STATUS)
|
||||
const giftStatuses: string[] = data?.giftStatuses || Object.keys(GIFT_STATUS)
|
||||
const progressLabels = (data?.progressStatusLabels || data?.statusOptions || PROGRESS_STATUS) as Record<string, string>
|
||||
const giftLabels = (data?.giftStatusLabels || data?.giftStatusOptions || GIFT_STATUS) as Record<string, string>
|
||||
const allowed: string[] = data?.allowedProgressStatuses || []
|
||||
const progressOpts = statusOptions(progressLabels, allowed.length ? allowed : undefined)
|
||||
const giftOpts = statusOptions(giftLabels)
|
||||
const progressLogs: any[] = data?.progressLogs || data?.logs || []
|
||||
const giftLogs: any[] = data?.giftProgressLogs || []
|
||||
|
||||
@@ -234,7 +240,7 @@ export default function ReceptionProgressModal({
|
||||
}
|
||||
}
|
||||
|
||||
const titleWarn = reception?.progressStatus === '9.취소' || reception?.detailProgress?.includes?.('주의')
|
||||
const titleWarn = reception?.progressStatus === 'f11' || reception?.progressStatus === '9.취소' || reception?.detailProgress?.includes?.('주의')
|
||||
const title = receptionId
|
||||
? `${titleWarn ? '⚠ 주의 요망 ' : ''}업무 진행 내역 — 접수 #${receptionId}`
|
||||
: '업무 진행 내역'
|
||||
@@ -276,7 +282,7 @@ export default function ReceptionProgressModal({
|
||||
<tr>
|
||||
<th>현재진행상황</th>
|
||||
<td colSpan={3}>
|
||||
<Input readOnly value={reception?.progressStatus || ''} style={{ width: 200 }} />
|
||||
<Input readOnly value={progressLabel(reception?.progressStatus) || reception?.progressStatus || ''} style={{ width: 200 }} />
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -301,7 +307,7 @@ export default function ReceptionProgressModal({
|
||||
style={{ minWidth: 180 }}
|
||||
value={f.status || undefined}
|
||||
onChange={(v) => set({ status: v || '' })}
|
||||
options={progressStatuses.map((s) => ({ value: s, label: s }))}
|
||||
options={progressOpts.length ? progressOpts : progressStatuses.map((s) => ({ value: s, label: progressLabel(s) || s }))}
|
||||
/>
|
||||
<Button size="small" loading={saveMut.isPending} onClick={() => saveMut.mutate(false)}>
|
||||
적 용
|
||||
@@ -433,7 +439,7 @@ export default function ReceptionProgressModal({
|
||||
<tr>
|
||||
<th>사은품현재상황</th>
|
||||
<td>
|
||||
<Input readOnly value={data.gift?.status || reception?.giftStatus || ''} />
|
||||
<Input readOnly value={giftLabel(data.gift?.status || reception?.giftStatus) || data.gift?.status || reception?.giftStatus || ''} />
|
||||
</td>
|
||||
<th>사은품진행상황</th>
|
||||
<td>
|
||||
@@ -443,7 +449,7 @@ export default function ReceptionProgressModal({
|
||||
style={{ width: '100%' }}
|
||||
value={f.giftStatus || undefined}
|
||||
onChange={(v) => set({ giftStatus: v || '' })}
|
||||
options={giftStatuses.map((s) => ({ value: s, label: s }))}
|
||||
options={giftOpts.length ? giftOpts : giftStatuses.map((s) => ({ value: s, label: giftLabel(s) || s }))}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -455,12 +461,7 @@ export default function ReceptionProgressModal({
|
||||
style={{ width: '100%' }}
|
||||
value={f.giftPlace || undefined}
|
||||
onChange={(v) => set({ giftPlace: v || '' })}
|
||||
options={[
|
||||
{ value: 'b01', label: '본사요청' },
|
||||
{ value: 'b02', label: '자체발송' },
|
||||
{ value: '본사', label: '본사' },
|
||||
{ value: '영업점', label: '영업점' },
|
||||
]}
|
||||
options={Object.entries(GIFT_PLACE).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</td>
|
||||
<th>지급요청/완료</th>
|
||||
|
||||
+38
-3
@@ -44,10 +44,25 @@ export function careMenuSections(): MenuSection[] {
|
||||
{ key: '/care/reception/list', label: '접수리스트' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'branch',
|
||||
title: '영업점관리',
|
||||
items: [
|
||||
{ key: '/care/branch/list', label: '영업점목록' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'invoice',
|
||||
title: '정산관리',
|
||||
items: [
|
||||
{ key: '/care/calculate/branch?tab=prepared', label: '가정산 리스트' },
|
||||
{ key: '/care/calculate/branch?tab=waiting', label: '정산대기 리스트' },
|
||||
{ key: '/care/calculate/branch?tab=completed', label: '정산완료 리스트' },
|
||||
{ key: '/care/calculate/branch?tab=transferred', label: '송금(환수)완료 리스트' },
|
||||
{ key: '/care/calculate/center?tab=prepared', label: '입력처 가정산 리스트' },
|
||||
{ key: '/care/calculate/center?tab=waiting', label: '입력처 정산대기 리스트' },
|
||||
{ key: '/care/calculate/center?tab=completed', label: '입력처 정산완료 리스트' },
|
||||
{ key: '/care/calculate/center?tab=transferred', label: '입금완료 리스트' },
|
||||
{ key: '/care/invoice/publish', label: '정산서발행' },
|
||||
{ key: '/care/invoice/my', label: '나의정산서' },
|
||||
{ key: '/care/invoice/log', label: '정산서이력' },
|
||||
@@ -109,6 +124,14 @@ export function careMenuSections(): MenuSection[] {
|
||||
{ key: '/care/setting/level', label: '권한설정' },
|
||||
{ key: '/care/setting/partner1', label: '상부점관리' },
|
||||
{ key: '/care/setting/partner2', label: '하부점관리' },
|
||||
{ key: '/care/setting/isp', label: '통신사관리' },
|
||||
{ key: '/care/setting/center', label: '입력처관리' },
|
||||
{ key: '/care/setting/isp-link', label: '통신사인증링크' },
|
||||
{ key: '/care/setting/board-admin', label: '게시판관리' },
|
||||
{ key: '/care/setting/fee', label: '수수료관리' },
|
||||
{ key: '/care/setting/quick-delivery', label: '퀵전송 연동' },
|
||||
{ key: '/care/setting/configure', label: '기타 환경 설정' },
|
||||
{ key: '/care/setting/usage', label: '이용현황' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -162,6 +185,7 @@ export const SITEMAP_TITLE_TO_ID: Record<string, string> = {
|
||||
상품관리: 'product',
|
||||
계약관리: 'contract',
|
||||
접수관리: 'reception',
|
||||
영업점관리: 'branch',
|
||||
정산관리: 'invoice',
|
||||
일정관리: 'pending',
|
||||
예약관리: 'booking',
|
||||
@@ -174,17 +198,28 @@ export const SITEMAP_TITLE_TO_ID: Record<string, string> = {
|
||||
게시판: 'bbs',
|
||||
}
|
||||
|
||||
export function resolveSelectedKey(pathname: string): string {
|
||||
export function resolveSelectedKey(pathname: string, search?: string): string {
|
||||
if (pathname.startsWith('/care/contract/write')) {
|
||||
const q = typeof window !== 'undefined' ? new URLSearchParams(window.location.search).get('type') : null
|
||||
const q = new URLSearchParams(search ?? (typeof window !== 'undefined' ? window.location.search : '')).get('type')
|
||||
if (q === 'home') return '/care/contract/home'
|
||||
return '/care/contract/internet'
|
||||
}
|
||||
if (pathname === '/care/sitemap') return '/care/main/sitemap'
|
||||
if (pathname.startsWith('/care/calculate/')) {
|
||||
const tab = new URLSearchParams(search ?? (typeof window !== 'undefined' ? window.location.search : '')).get('tab') || 'prepared'
|
||||
return `${pathname}?tab=${tab}`
|
||||
}
|
||||
return pathname
|
||||
}
|
||||
|
||||
export function sectionContainsPath(section: MenuSection, pathname: string): boolean {
|
||||
const selected = resolveSelectedKey(pathname)
|
||||
return section.items.some((it) => selected === it.key || selected.startsWith(`${it.key}/`))
|
||||
return section.items.some((it) => {
|
||||
if (selected === it.key) return true
|
||||
if (selected.startsWith(`${it.key}/`)) return true
|
||||
// 정산 메뉴: pathname 만으로도 섹션 활성
|
||||
const itPath = it.key.split('?')[0]
|
||||
if (pathname === itPath || pathname.startsWith(`${itPath}/`)) return true
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
@@ -592,7 +592,7 @@ export default function AbooksPage() {
|
||||
</Form.Item>
|
||||
<div className="balance-modal-footer">
|
||||
<Button className="abook-submit-btn" type="primary" htmlType="submit" loading={save.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
|
||||
@@ -572,7 +572,7 @@ export default function BookingPage() {
|
||||
<div className="booking-modal-note">* 고객명, 연락처는 고객관리에도 동시등록됩니다.</div>
|
||||
<Space>
|
||||
<Button className="booking-submit-btn" type="primary" htmlType="submit" loading={save.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
|
||||
</Space>
|
||||
|
||||
@@ -0,0 +1,531 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import DraggableModal from '../../components/DraggableModal'
|
||||
import { FormField, FormGrid, FormSection } from '../../components/OrderFormLayout'
|
||||
|
||||
export type FeeEntry = {
|
||||
id: number
|
||||
type: string
|
||||
ispName?: string
|
||||
carrier?: string
|
||||
category?: string
|
||||
startDate?: string
|
||||
}
|
||||
|
||||
type FeeProduct = {
|
||||
id?: number
|
||||
productType: string
|
||||
productName: string
|
||||
fee: number
|
||||
}
|
||||
|
||||
type FeeDetail = {
|
||||
fee: {
|
||||
id: number
|
||||
type: string
|
||||
carrier?: string
|
||||
ispName?: string
|
||||
startDate?: string
|
||||
memo?: string
|
||||
source?: string
|
||||
file1?: string
|
||||
file2?: string
|
||||
file3?: string
|
||||
file4?: string
|
||||
file5?: string
|
||||
branchName?: string
|
||||
}
|
||||
products: FeeProduct[]
|
||||
}
|
||||
|
||||
const PRODUCT_TYPE: Record<string, string> = {
|
||||
e01: '인터넷',
|
||||
e02: '전화',
|
||||
e03: 'TV',
|
||||
e04: '기타',
|
||||
}
|
||||
|
||||
const CARRIERS = ['KT', 'SKB', 'LGU+', 'SKT', '기타']
|
||||
|
||||
const won = (n: number) => `${(n ?? 0).toLocaleString('ko-KR')} 원`
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
branchId: number | null
|
||||
branchName: string
|
||||
onClose: () => void
|
||||
}
|
||||
|
||||
/** 영업점 수수료 목록 → 상세보기(개별/그룹) → 저장 — billing_imeere branchFees 흐름 */
|
||||
export default function BranchFeeFlowModals({ open, branchId, branchName, onClose }: Props) {
|
||||
const qc = useQueryClient()
|
||||
const [selectedFeeKey, setSelectedFeeKey] = useState<string | null>(null)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [detailType, setDetailType] = useState<'alone' | 'group'>('alone')
|
||||
const [detailFeeId, setDetailFeeId] = useState<number | null>(null)
|
||||
const [editable, setEditable] = useState(false)
|
||||
const [memo, setMemo] = useState('')
|
||||
const [startDate, setStartDate] = useState<string>('')
|
||||
const [products, setProducts] = useState<FeeProduct[]>([])
|
||||
const [selectedProductKey, setSelectedProductKey] = useState<string | null>(null)
|
||||
const [createForm] = Form.useForm()
|
||||
|
||||
const { data: entries = [], isLoading } = useQuery({
|
||||
queryKey: ['homes-branch-fees', branchId],
|
||||
enabled: !!branchId && open,
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<FeeEntry[]>>(
|
||||
`${apiPrefix()}/homes/branches/${branchId}/fees`,
|
||||
)
|
||||
return unwrap(data) || []
|
||||
},
|
||||
})
|
||||
|
||||
const { data: detail, isLoading: detailLoading } = useQuery({
|
||||
queryKey: ['homes-branch-fee-detail', detailFeeId, detailType],
|
||||
enabled: !!detailFeeId && detailOpen,
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<FeeDetail>>(
|
||||
`${apiPrefix()}/homes/branch-fees/${detailFeeId}`,
|
||||
{ params: { type: detailType } },
|
||||
)
|
||||
return unwrap(data)
|
||||
},
|
||||
})
|
||||
|
||||
const { data: templates = [] } = useQuery({
|
||||
queryKey: ['homes-branch-fee-templates'],
|
||||
enabled: createOpen,
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<FeeProduct[]>>(
|
||||
`${apiPrefix()}/homes/branch-fee-product-templates`,
|
||||
)
|
||||
return unwrap(data) || []
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!detail) return
|
||||
setMemo(detail.fee.memo || '')
|
||||
setStartDate(detail.fee.startDate || '')
|
||||
setProducts((detail.products || []).map((p) => ({ ...p, fee: p.fee ?? 0 })))
|
||||
setEditable(detail.fee.type === 'alone')
|
||||
}, [detail])
|
||||
|
||||
useEffect(() => {
|
||||
if (!createOpen) return
|
||||
const rows = templates.length
|
||||
? templates.map((t) => ({ ...t, fee: 0 }))
|
||||
: [{ productType: 'e01', productName: '기가인터넷 1G', fee: 0 }]
|
||||
createForm.setFieldsValue({
|
||||
carrier: createForm.getFieldValue('carrier') || 'KT',
|
||||
startDate: createForm.getFieldValue('startDate') || dayjs().startOf('day'),
|
||||
products: rows,
|
||||
})
|
||||
}, [createOpen, templates, createForm])
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: async (feeId: number) => {
|
||||
const { data } = await client.delete(`${apiPrefix()}/homes/branch-fees/${feeId}`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
qc.invalidateQueries({ queryKey: ['homes-branch-fees', branchId] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await client.put(
|
||||
`${apiPrefix()}/homes/branch-fees/${detailFeeId}`,
|
||||
{
|
||||
startDate,
|
||||
memo,
|
||||
carrier: detail?.fee.carrier || detail?.fee.ispName,
|
||||
products: products.map((p) => ({
|
||||
productType: p.productType,
|
||||
productName: p.productName,
|
||||
fee: p.fee ?? 0,
|
||||
})),
|
||||
},
|
||||
)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('수정되었습니다.')
|
||||
qc.invalidateQueries({ queryKey: ['homes-branch-fee-detail', detailFeeId, detailType] })
|
||||
qc.invalidateQueries({ queryKey: ['homes-branch-fees', branchId] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const productRows: FeeProduct[] = (values.products as FeeProduct[]) || templates
|
||||
const sd = values.startDate
|
||||
const startDateStr = sd && typeof sd === 'object' && 'format' in (sd as object)
|
||||
? (sd as { format: (f: string) => string }).format('YYYY-MM-DD HH:mm:ss')
|
||||
: (sd ? dayjs(String(sd)).format('YYYY-MM-DD HH:mm:ss') : dayjs().format('YYYY-MM-DD 00:00:00'))
|
||||
const { data } = await client.post(
|
||||
`${apiPrefix()}/homes/branches/${branchId}/fees`,
|
||||
{
|
||||
carrier: values.carrier,
|
||||
startDate: startDateStr,
|
||||
memo: values.memo || '',
|
||||
products: productRows.map((p) => ({
|
||||
productType: p.productType,
|
||||
productName: p.productName,
|
||||
fee: Number(p.fee) || 0,
|
||||
})),
|
||||
},
|
||||
)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('등록되었습니다.')
|
||||
setCreateOpen(false)
|
||||
createForm.resetFields()
|
||||
qc.invalidateQueries({ queryKey: ['homes-branch-fees', branchId] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const openDetail = (row: FeeEntry, editMode: boolean) => {
|
||||
setDetailType(row.type === 'group' ? 'group' : 'alone')
|
||||
setDetailFeeId(row.id)
|
||||
setDetailOpen(true)
|
||||
setEditable(row.type === 'alone' && editMode)
|
||||
// if open via 상세보기 for alone, still allow save (screenshot has 저장)
|
||||
if (row.type === 'alone') setEditable(true)
|
||||
if (row.type === 'group') setEditable(false)
|
||||
}
|
||||
|
||||
const backToList = () => {
|
||||
setDetailOpen(false)
|
||||
setDetailFeeId(null)
|
||||
}
|
||||
|
||||
const titleKind = detailType === 'group' ? '그룹' : '개별'
|
||||
const files = useMemo(() => ([
|
||||
detail?.fee.file1, detail?.fee.file2, detail?.fee.file3, detail?.fee.file4, detail?.fee.file5,
|
||||
]), [detail])
|
||||
|
||||
return (
|
||||
<>
|
||||
<DraggableModal
|
||||
title={`영업점 수수료 - ${branchName}`}
|
||||
open={open && !detailOpen && !createOpen}
|
||||
onCancel={onClose}
|
||||
width={780}
|
||||
destroyOnClose={false}
|
||||
footer={(
|
||||
<Space>
|
||||
<Button type="primary" onClick={() => {
|
||||
createForm.resetFields()
|
||||
createForm.setFieldsValue({
|
||||
carrier: 'KT',
|
||||
startDate: dayjs().startOf('day'),
|
||||
memo: '',
|
||||
})
|
||||
setCreateOpen(true)
|
||||
}}
|
||||
>
|
||||
등록
|
||||
</Button>
|
||||
<Button onClick={onClose}>목록</Button>
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
<Table
|
||||
size="small"
|
||||
rowKey={(r) => `${r.type}-${r.id}`}
|
||||
loading={isLoading}
|
||||
dataSource={entries}
|
||||
pagination={{ pageSize: 20, showSizeChanger: false }}
|
||||
locale={{ emptyText: '등록된 수수료가 없습니다.' }}
|
||||
onRow={(r) => ({
|
||||
onClick: () => setSelectedFeeKey(`${r.type}-${r.id}`),
|
||||
className: selectedFeeKey === `${r.type}-${r.id}` ? 'row-selected' : undefined,
|
||||
})}
|
||||
columns={[
|
||||
{
|
||||
title: '판매처',
|
||||
dataIndex: 'ispName',
|
||||
render: (v, r) => v || r.carrier || '',
|
||||
},
|
||||
{
|
||||
title: '적용일',
|
||||
dataIndex: 'startDate',
|
||||
align: 'center',
|
||||
width: 170,
|
||||
},
|
||||
{
|
||||
title: '분류',
|
||||
dataIndex: 'category',
|
||||
align: 'center',
|
||||
width: 80,
|
||||
render: (v, r) => v || (r.type === 'group' ? '그룹' : '공통'),
|
||||
},
|
||||
{
|
||||
title: '상세보기',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
render: (_, r) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="branch-row-btn"
|
||||
onClick={(e) => { e.stopPropagation(); openDetail(r, false) }}
|
||||
>
|
||||
상세보기
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '수정',
|
||||
width: 90,
|
||||
align: 'center',
|
||||
render: (_, r) => (r.type === 'alone'
|
||||
? (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="branch-row-btn"
|
||||
onClick={(e) => { e.stopPropagation(); openDetail(r, true) }}
|
||||
>
|
||||
수정하기
|
||||
</Button>
|
||||
)
|
||||
: '-'),
|
||||
},
|
||||
{
|
||||
title: '삭제',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
render: (_, r) => (r.type === 'alone'
|
||||
? (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
danger
|
||||
className="branch-row-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (window.confirm('정말로 삭제하시겠습니까?')) deleteMut.mutate(r.id)
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
)
|
||||
: '-'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DraggableModal>
|
||||
|
||||
<DraggableModal
|
||||
title={`영업점 ${titleKind} 수수료 - ${branchName}`}
|
||||
open={detailOpen}
|
||||
onCancel={backToList}
|
||||
width={800}
|
||||
destroyOnClose
|
||||
className="order-form-modal branch-form-modal"
|
||||
styles={{ body: { maxHeight: 'calc(92vh - 56px)', overflowY: 'auto' } }}
|
||||
footer={(
|
||||
<Space>
|
||||
<Button onClick={backToList}>목록으로</Button>
|
||||
{editable && (
|
||||
<Button type="primary" loading={saveMut.isPending} onClick={() => saveMut.mutate()}>
|
||||
저장
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
{detailLoading || !detail ? (
|
||||
<Typography.Text type="secondary">불러오는 중...</Typography.Text>
|
||||
) : (
|
||||
<div className="order-form-embedded-inner order-form-page reception-apply-form branch-fee-detail">
|
||||
<FormSection title="기본 정보">
|
||||
<FormGrid>
|
||||
<FormField label="출처">
|
||||
<span>{detail.fee.source || detail.fee.id}</span>
|
||||
</FormField>
|
||||
<FormField label="판매처">
|
||||
<span>{detail.fee.carrier || detail.fee.ispName || '-'}</span>
|
||||
</FormField>
|
||||
<FormField label="적용일자" full>
|
||||
{editable ? (
|
||||
<DatePicker
|
||||
showTime
|
||||
value={startDate ? dayjs(startDate) : null}
|
||||
onChange={(d) => setStartDate(d ? d.format('YYYY-MM-DD HH:mm:ss') : '')}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
) : (detail.fee.startDate || '-')}
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</FormSection>
|
||||
|
||||
<FormSection title="상품별 수수료">
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey={(r) => `${r.productType}-${r.productName}`}
|
||||
dataSource={products}
|
||||
locale={{ emptyText: '상품별 수수료가 없습니다.' }}
|
||||
onRow={(r) => ({
|
||||
onClick: () => setSelectedProductKey(`${r.productType}-${r.productName}`),
|
||||
className: selectedProductKey === `${r.productType}-${r.productName}` ? 'row-selected' : undefined,
|
||||
})}
|
||||
columns={[
|
||||
{
|
||||
title: '상품유형',
|
||||
dataIndex: 'productType',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (t: string) => PRODUCT_TYPE[t] || t || '기타',
|
||||
},
|
||||
{ title: '상품명', dataIndex: 'productName' },
|
||||
{
|
||||
title: '적용수수료',
|
||||
dataIndex: 'fee',
|
||||
width: 140,
|
||||
align: 'right',
|
||||
render: (fee: number, _r, idx) => (editable ? (
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={1000}
|
||||
value={fee}
|
||||
style={{ width: 120 }}
|
||||
onChange={(v) => {
|
||||
setProducts((prev) => prev.map((p, i) => (i === idx ? { ...p, fee: Number(v) || 0 } : p)))
|
||||
}}
|
||||
/>
|
||||
) : won(fee)),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</FormSection>
|
||||
|
||||
<FormSection title="기타">
|
||||
<FormGrid cols={1}>
|
||||
<FormField label="메모" full>
|
||||
{editable ? (
|
||||
<Input.TextArea rows={2} value={memo} onChange={(e) => setMemo(e.target.value)} />
|
||||
) : (memo || '')}
|
||||
</FormField>
|
||||
{[1, 2, 3, 4, 5].map((n) => (
|
||||
<FormField key={n} label={`파일${n}`} full>
|
||||
<span>{files[n - 1] || ''}</span>
|
||||
</FormField>
|
||||
))}
|
||||
</FormGrid>
|
||||
</FormSection>
|
||||
</div>
|
||||
)}
|
||||
</DraggableModal>
|
||||
|
||||
<DraggableModal
|
||||
title={`영업점 개별 수수료 등록 - ${branchName}`}
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={() => createForm.submit()}
|
||||
confirmLoading={createMut.isPending}
|
||||
okText="등록"
|
||||
width={800}
|
||||
destroyOnClose
|
||||
className="order-form-modal branch-form-modal"
|
||||
styles={{ body: { maxHeight: 'calc(92vh - 56px)', overflowY: 'auto' } }}
|
||||
>
|
||||
<Form
|
||||
form={createForm}
|
||||
layout="vertical"
|
||||
className="order-form-embedded-inner order-form-page reception-apply-form"
|
||||
onFinish={(v) => createMut.mutate(v)}
|
||||
initialValues={{
|
||||
carrier: 'KT',
|
||||
startDate: dayjs().startOf('day'),
|
||||
}}
|
||||
>
|
||||
<FormSection title="기본 정보">
|
||||
<FormGrid>
|
||||
<FormField label="판매처(통신사)*">
|
||||
<Form.Item name="carrier" noStyle rules={[{ required: true, message: '통신사를 선택하세요' }]}>
|
||||
<Select options={CARRIERS.map((c) => ({ value: c, label: c }))} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="적용일*">
|
||||
<Form.Item name="startDate" noStyle rules={[{ required: true, message: '적용일을 입력하세요' }]}>
|
||||
<DatePicker showTime style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="메모" full>
|
||||
<Form.Item name="memo" noStyle><Input.TextArea rows={2} /></Form.Item>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</FormSection>
|
||||
<FormSection title="상품별 수수료">
|
||||
<Form.List name="products">
|
||||
{(fields) => (
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={fields.map((f) => ({ ...f, key: f.key }))}
|
||||
columns={[
|
||||
{
|
||||
title: '상품유형',
|
||||
width: 120,
|
||||
render: (_, field) => (
|
||||
<Form.Item name={[field.name, 'productType']} noStyle>
|
||||
<Select
|
||||
options={Object.entries(PRODUCT_TYPE).map(([k, v]) => ({ value: k, label: v }))}
|
||||
style={{ width: 100 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '상품명',
|
||||
render: (_, field) => (
|
||||
<Form.Item name={[field.name, 'productName']} noStyle>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '적용수수료',
|
||||
width: 130,
|
||||
render: (_, field) => (
|
||||
<Form.Item name={[field.name, 'fee']} noStyle>
|
||||
<InputNumber min={0} style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Form.List>
|
||||
</FormSection>
|
||||
</Form>
|
||||
</DraggableModal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,788 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Radio,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import type { ColumnsType } from 'antd/es/table'
|
||||
import { PlusOutlined, SearchOutlined, ShopOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import DraggableModal from '../../components/DraggableModal'
|
||||
import { FormField, FormGrid, FormSection } from '../../components/OrderFormLayout'
|
||||
import BranchFeeFlowModals from './BranchFeeFlowModals'
|
||||
|
||||
type BranchRow = {
|
||||
id: number
|
||||
name?: string
|
||||
officeName?: string
|
||||
userid?: string
|
||||
phone?: string
|
||||
email?: string
|
||||
sido?: string
|
||||
level?: number
|
||||
grant?: number
|
||||
branchGroupId?: number | null
|
||||
branchGroupName?: string | null
|
||||
hasBusinessLicense?: boolean
|
||||
isTax?: number
|
||||
createdAt?: string
|
||||
}
|
||||
|
||||
type BranchGroup = { id: number; name: string }
|
||||
|
||||
type BranchDetail = BranchRow & {
|
||||
fax?: string
|
||||
zipcode?: string
|
||||
address?: string
|
||||
addressDetail?: string
|
||||
tel?: string
|
||||
centerName?: string
|
||||
isPersonal?: boolean
|
||||
accountBank?: string
|
||||
accountNumber?: string
|
||||
accountName?: string
|
||||
contactPhone?: string
|
||||
contactNateon?: string
|
||||
acceptPhone?: number
|
||||
acceptNateon?: number
|
||||
memo?: string
|
||||
adminMemo?: string
|
||||
licenseNumber?: string
|
||||
licenseType?: string
|
||||
licenseItem?: string
|
||||
notifyPhones?: string[]
|
||||
permissionOrderDateUpdate?: boolean
|
||||
permissionCalculate?: boolean
|
||||
permissionConfig?: boolean
|
||||
permissionExcelDownload?: boolean
|
||||
permissionChart?: boolean
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 50
|
||||
const BANKS = ['국민은행', '우리은행', '신한은행', '하나은행', '기업은행', '농협', 'SC제일', '카카오뱅크', '기타']
|
||||
|
||||
function levelLabel(l?: number) {
|
||||
if (l === 5) return <span style={{ color: '#04A600' }}>센터</span>
|
||||
if (l === 9) return <span style={{ color: '#0F6DC4' }}>관리자</span>
|
||||
if (l === 10) return <span style={{ color: '#cc0000' }}>최고관리자</span>
|
||||
return <span>영업점</span>
|
||||
}
|
||||
|
||||
function EditBranchFormBody({
|
||||
groups,
|
||||
centers,
|
||||
}: {
|
||||
groups: BranchGroup[]
|
||||
centers: { id: number; name: string }[]
|
||||
}) {
|
||||
const isPersonal = Form.useWatch('isPersonal')
|
||||
const level = Form.useWatch('level')
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormSection title="기본 정보">
|
||||
<FormGrid cols={1}>
|
||||
<FormField label="유형" full>
|
||||
<Form.Item name="isPersonal" noStyle>
|
||||
<Radio.Group
|
||||
className="of-antd-choice-row"
|
||||
style={{ padding: 0, border: 'none' }}
|
||||
options={[
|
||||
{ value: true, label: '개인' },
|
||||
{ value: false, label: '사업자 (일반/법인)' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
<FormGrid>
|
||||
<FormField label="영업점 그룹">
|
||||
<Form.Item name="branchGroupId" noStyle>
|
||||
<Select allowClear placeholder="영업점 그룹 선택" options={groups.map((g) => ({ value: g.id, label: g.name }))} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="수수료 유지">
|
||||
<Form.Item name="keepFee" valuePropName="checked" noStyle>
|
||||
<Checkbox>그룹 변경 시 수수료 유지</Checkbox>
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="레벨">
|
||||
<Form.Item name="level" noStyle>
|
||||
<Select options={[
|
||||
{ value: 1, label: '영업점' },
|
||||
{ value: 5, label: '센터' },
|
||||
{ value: 9, label: '관리자' },
|
||||
{ value: 10, label: '최고관리자' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="입력처">
|
||||
{level === 5 ? (
|
||||
<Form.Item name="centerName" noStyle>
|
||||
<Select allowClear options={centers.map((c) => ({ value: c.name, label: c.name }))} />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<span className="of-muted">센터 레벨에서 선택</span>
|
||||
)}
|
||||
</FormField>
|
||||
{level === 9 && (
|
||||
<FormField label="권한" full>
|
||||
<Space wrap size={[8, 4]}>
|
||||
<Form.Item name="permissionOrderDateUpdate" valuePropName="checked" noStyle><Checkbox>접수일자 변경</Checkbox></Form.Item>
|
||||
<Form.Item name="permissionCalculate" valuePropName="checked" noStyle><Checkbox>정산</Checkbox></Form.Item>
|
||||
<Form.Item name="permissionConfig" valuePropName="checked" noStyle><Checkbox>환경설정</Checkbox></Form.Item>
|
||||
<Form.Item name="permissionExcelDownload" valuePropName="checked" noStyle><Checkbox>엑셀 다운로드</Checkbox></Form.Item>
|
||||
<Form.Item name="permissionChart" valuePropName="checked" noStyle><Checkbox>통계</Checkbox></Form.Item>
|
||||
</Space>
|
||||
</FormField>
|
||||
)}
|
||||
<FormField label="이름*">
|
||||
<Form.Item name="name" noStyle rules={[{ required: true }]}><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="영업점명*">
|
||||
<Form.Item name="officeName" noStyle rules={[{ required: true }]}><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="아이디">
|
||||
<Form.Item name="userid" noStyle><Input disabled /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="패스워드">
|
||||
<div style={{ display: 'flex', gap: 8, width: '100%' }}>
|
||||
<Form.Item name="password" noStyle style={{ flex: 1, margin: 0 }}>
|
||||
<Input.Password placeholder="변경 시 입력" />
|
||||
</Form.Item>
|
||||
<Form.Item name="passwordConfirmation" noStyle style={{ flex: 1, margin: 0 }}>
|
||||
<Input.Password placeholder="확인" />
|
||||
</Form.Item>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="이메일" full>
|
||||
<Form.Item name="email" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="휴대폰">
|
||||
<Form.Item name="phone" noStyle><Input placeholder="010-0000-0000" /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="연락처 / 팩스">
|
||||
<div style={{ display: 'flex', gap: 8, width: '100%' }}>
|
||||
<Form.Item name="tel" noStyle style={{ flex: 1, margin: 0 }}><Input placeholder="전화" /></Form.Item>
|
||||
<Form.Item name="fax" noStyle style={{ flex: 1, margin: 0 }}><Input placeholder="팩스" /></Form.Item>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="우편번호">
|
||||
<Form.Item name="zipcode" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="주소">
|
||||
<Form.Item name="address" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="상세주소" full>
|
||||
<Form.Item name="addressDetail" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
{isPersonal === false && (
|
||||
<>
|
||||
<FormField label="사업자등록번호">
|
||||
<Form.Item name="licenseNumber" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="업태 / 업종">
|
||||
<div style={{ display: 'flex', gap: 8, width: '100%' }}>
|
||||
<Form.Item name="licenseType" noStyle style={{ flex: 1, margin: 0 }}><Input placeholder="업태" /></Form.Item>
|
||||
<Form.Item name="licenseItem" noStyle style={{ flex: 1, margin: 0 }}><Input placeholder="업종" /></Form.Item>
|
||||
</div>
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
</FormGrid>
|
||||
</FormSection>
|
||||
|
||||
<FormSection title="결제 정보">
|
||||
<FormGrid>
|
||||
<FormField label="은행">
|
||||
<Form.Item name="accountBank" noStyle>
|
||||
<Select allowClear options={BANKS.map((b) => ({ value: b, label: b }))} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="계좌번호">
|
||||
<Form.Item name="accountNumber" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="예금주">
|
||||
<div style={{ display: 'flex', gap: 8, width: '100%', alignItems: 'center' }}>
|
||||
<Form.Item name="accountName" noStyle style={{ flex: 1, margin: 0 }}><Input /></Form.Item>
|
||||
<Form.Item name="accountNameIsName" valuePropName="checked" noStyle>
|
||||
<Checkbox>정보동일</Checkbox>
|
||||
</Form.Item>
|
||||
</div>
|
||||
</FormField>
|
||||
<FormField label="세금계산서">
|
||||
<Form.Item name="isTax" noStyle>
|
||||
<Select options={[
|
||||
{ value: 1, label: '세금계산서발행' },
|
||||
{ value: 0, label: '종합소득세공제' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</FormSection>
|
||||
|
||||
<FormSection title="업무진행 상황 수신정보">
|
||||
<FormGrid>
|
||||
<FormField label="수신설정" full>
|
||||
<Space wrap>
|
||||
<Form.Item name="acceptPhone" valuePropName="checked" noStyle><Checkbox>핸드폰 문자 받음</Checkbox></Form.Item>
|
||||
<Form.Item name="acceptNateon" valuePropName="checked" noStyle><Checkbox>네이트온 쪽지 받음</Checkbox></Form.Item>
|
||||
</Space>
|
||||
</FormField>
|
||||
<FormField label="휴대폰">
|
||||
<Form.Item name="contactPhone" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="네이트온 아이디">
|
||||
<Form.Item name="contactNateon" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="정책 수신 휴대폰" full>
|
||||
<Form.List name="notifyPhones">
|
||||
{(fields) => (
|
||||
<Space wrap>
|
||||
{fields.map((field) => (
|
||||
<Form.Item {...field} key={field.key} noStyle>
|
||||
<Input style={{ width: 140 }} placeholder={`휴대폰 ${field.name + 1}`} />
|
||||
</Form.Item>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</Form.List>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</FormSection>
|
||||
|
||||
<FormSection title="메모">
|
||||
<FormGrid cols={1}>
|
||||
<FormField label="메모" full>
|
||||
<Form.Item name="memo" noStyle><Input.TextArea rows={2} /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="관리자 메모" full>
|
||||
<Form.Item name="adminMemo" noStyle><Input.TextArea rows={2} /></Form.Item>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</FormSection>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function BranchListPage() {
|
||||
const qc = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [feeOpen, setFeeOpen] = useState(false)
|
||||
const [feeBranch, setFeeBranch] = useState<BranchRow | null>(null)
|
||||
const [editingId, setEditingId] = useState<number | null>(null)
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null)
|
||||
const [page, setPage] = useState(0)
|
||||
const [draft, setDraft] = useState({ fGroup: '', fSido: '', fUserid: '', fOffice: '', fName: '' })
|
||||
const [applied, setApplied] = useState(draft)
|
||||
|
||||
const { data: rows = [], isLoading } = useQuery({
|
||||
queryKey: ['homes-branches'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<BranchRow[]>>(`${apiPrefix()}/homes/branches`)
|
||||
return unwrap(data) || []
|
||||
},
|
||||
})
|
||||
|
||||
const { data: groups = [] } = useQuery({
|
||||
queryKey: ['homes-branch-groups'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<BranchGroup[]>>(`${apiPrefix()}/homes/branch-groups`)
|
||||
return unwrap(data) || []
|
||||
},
|
||||
})
|
||||
|
||||
const { data: centers = [] } = useQuery({
|
||||
queryKey: ['homes-branch-centers'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<{ id: number; name: string }[]>>(`${apiPrefix()}/homes/branch-centers`)
|
||||
return unwrap(data) || []
|
||||
},
|
||||
})
|
||||
|
||||
const sidoOptions = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
rows.forEach((b) => { if (b.sido) set.add(b.sido) })
|
||||
return Array.from(set).sort()
|
||||
}, [rows])
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const inc = (v: string | null | undefined, q: string) => (v ?? '').toLowerCase().includes(q.toLowerCase())
|
||||
return rows.filter((b) =>
|
||||
(!applied.fGroup || String(b.branchGroupId ?? '') === applied.fGroup)
|
||||
&& (!applied.fSido || b.sido === applied.fSido)
|
||||
&& (!applied.fUserid || inc(b.userid, applied.fUserid))
|
||||
&& (!applied.fOffice || inc(b.officeName, applied.fOffice))
|
||||
&& (!applied.fName || inc(b.name, applied.fName)),
|
||||
)
|
||||
}, [rows, applied])
|
||||
|
||||
const pageRows = filtered.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE)
|
||||
|
||||
const grantMut = useMutation({
|
||||
mutationFn: async ({ id, g }: { id: number; g: number }) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/branches/${id}/grant/${g}`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['homes-branches'] })
|
||||
message.success('승인 상태를 변경했습니다.')
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const params = new URLSearchParams({ level: String(values.level ?? 1) })
|
||||
if (values.branchGroupId) params.set('branchGroupId', String(values.branchGroupId))
|
||||
const { data } = await client.post(
|
||||
`${apiPrefix()}/homes/branches?${params.toString()}`,
|
||||
values,
|
||||
)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('영업점을 등록했습니다.')
|
||||
setCreateOpen(false)
|
||||
form.resetFields()
|
||||
qc.invalidateQueries({ queryKey: ['homes-branches'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const { data } = await client.put(
|
||||
`${apiPrefix()}/homes/branches/${editingId}/detail`,
|
||||
values,
|
||||
)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('수정했습니다.')
|
||||
setEditOpen(false)
|
||||
setEditingId(null)
|
||||
qc.invalidateQueries({ queryKey: ['homes-branches'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const deleteMut = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.delete(`${apiPrefix()}/homes/branches/${id}`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
qc.invalidateQueries({ queryKey: ['homes-branches'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const openEdit = async (row: BranchRow) => {
|
||||
try {
|
||||
const { data } = await client.get<ApiResponse<BranchDetail>>(
|
||||
`${apiPrefix()}/homes/branches/${row.id}/detail`,
|
||||
)
|
||||
const detail = unwrap(data)
|
||||
setEditingId(row.id)
|
||||
editForm.setFieldsValue({
|
||||
...detail,
|
||||
isPersonal: detail.isPersonal !== false,
|
||||
notifyPhones: [...(detail.notifyPhones || []), '', '', '', '', '', ''].slice(0, 6),
|
||||
keepFee: false,
|
||||
password: '',
|
||||
passwordConfirmation: '',
|
||||
accountNameIsName: false,
|
||||
acceptPhone: detail.acceptPhone === 1,
|
||||
acceptNateon: detail.acceptNateon === 1,
|
||||
})
|
||||
setEditOpen(true)
|
||||
} catch (e) {
|
||||
message.error((e as Error).message)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = (row: BranchRow) => {
|
||||
Modal.confirm({
|
||||
title: '영업점 삭제',
|
||||
content: (
|
||||
<div>
|
||||
<p><b>{row.officeName}</b> 영업점을 삭제하시겠습니까?</p>
|
||||
<p className="muted">관련 접수·정산 자료는 삭제되지 않지만, 영업점 계정 정보는 제거됩니다.</p>
|
||||
</div>
|
||||
),
|
||||
okText: '삭제',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '취소',
|
||||
onOk: () => deleteMut.mutateAsync(row.id),
|
||||
})
|
||||
}
|
||||
|
||||
const columns: ColumnsType<BranchRow> = [
|
||||
{ title: '그룹', dataIndex: 'branchGroupName', width: 110, ellipsis: true },
|
||||
{ title: '지역', dataIndex: 'sido', width: 70, align: 'center' },
|
||||
{ title: '아이디', dataIndex: 'userid', width: 100, ellipsis: true },
|
||||
{ title: '영업점명', dataIndex: 'officeName', width: 120, ellipsis: true },
|
||||
{ title: '이름', dataIndex: 'name', width: 90, ellipsis: true },
|
||||
{
|
||||
title: '레벨',
|
||||
dataIndex: 'level',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
render: (v) => levelLabel(v),
|
||||
},
|
||||
{ title: '연락처', dataIndex: 'phone', width: 120 },
|
||||
{
|
||||
title: '사업자',
|
||||
dataIndex: 'hasBusinessLicense',
|
||||
width: 70,
|
||||
align: 'center',
|
||||
render: (v) => (v ? '사업자' : '개인'),
|
||||
},
|
||||
{
|
||||
title: '세금계산서',
|
||||
dataIndex: 'isTax',
|
||||
width: 110,
|
||||
align: 'center',
|
||||
render: (v) => (v === 1 ? '세금계산서발행' : '종합소득세공제'),
|
||||
},
|
||||
{
|
||||
title: '회원가입일',
|
||||
dataIndex: 'createdAt',
|
||||
width: 150,
|
||||
align: 'center',
|
||||
render: (v?: string) => v?.replace('T', ' ').substring(0, 19),
|
||||
},
|
||||
{
|
||||
title: '수수료',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
render: (_, r) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="branch-row-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setFeeBranch(r)
|
||||
setFeeOpen(true)
|
||||
}}
|
||||
>
|
||||
수수료
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '승인여부',
|
||||
width: 80,
|
||||
align: 'center',
|
||||
render: (_, r) => (
|
||||
r.grant === 1
|
||||
? (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="branch-row-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
grantMut.mutate({ id: r.id, g: 0 })
|
||||
}}
|
||||
>
|
||||
승인
|
||||
</Button>
|
||||
)
|
||||
: (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
danger
|
||||
className="branch-row-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
grantMut.mutate({ id: r.id, g: 1 })
|
||||
}}
|
||||
>
|
||||
거부
|
||||
</Button>
|
||||
)
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '수정',
|
||||
width: 70,
|
||||
align: 'center',
|
||||
render: (_, r) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="branch-row-btn"
|
||||
onClick={(e) => { e.stopPropagation(); void openEdit(r) }}
|
||||
>
|
||||
수정
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '삭제',
|
||||
width: 70,
|
||||
align: 'center',
|
||||
render: (_, r) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
danger
|
||||
className="branch-row-btn"
|
||||
onClick={(e) => { e.stopPropagation(); confirmDelete(r) }}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="stock-page branch-list-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<ShopOutlined className="product-title-icon" />
|
||||
영업점 목록
|
||||
</h2>
|
||||
<p>영업점을 검색·등록하고 승인·수수료·상세정보를 관리합니다.</p>
|
||||
</div>
|
||||
<Button type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); form.setFieldsValue({ level: 1, isTax: 1 }); setCreateOpen(true) }}>
|
||||
등록
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="stock-table-card" size="small">
|
||||
<Space wrap style={{ marginBottom: 12 }} size={8}>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="그룹 선택"
|
||||
style={{ width: 140 }}
|
||||
value={draft.fGroup || undefined}
|
||||
onChange={(v) => setDraft((d) => ({ ...d, fGroup: v ?? '' }))}
|
||||
options={groups.map((g) => ({ value: String(g.id), label: g.name }))}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="지역 선택"
|
||||
style={{ width: 120 }}
|
||||
value={draft.fSido || undefined}
|
||||
onChange={(v) => setDraft((d) => ({ ...d, fSido: v ?? '' }))}
|
||||
options={sidoOptions.map((s) => ({ value: s, label: s }))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="아이디"
|
||||
style={{ width: 120 }}
|
||||
value={draft.fUserid}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, fUserid: e.target.value }))}
|
||||
onPressEnter={() => { setApplied(draft); setPage(0) }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="영업점명"
|
||||
style={{ width: 120 }}
|
||||
value={draft.fOffice}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, fOffice: e.target.value }))}
|
||||
onPressEnter={() => { setApplied(draft); setPage(0) }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="이름"
|
||||
style={{ width: 100 }}
|
||||
value={draft.fName}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, fName: e.target.value }))}
|
||||
onPressEnter={() => { setApplied(draft); setPage(0) }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SearchOutlined />}
|
||||
onClick={() => { setApplied(draft); setPage(0) }}
|
||||
>
|
||||
검색
|
||||
</Button>
|
||||
<Typography.Text type="secondary">총 {filtered.length}건</Typography.Text>
|
||||
</Space>
|
||||
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
loading={isLoading}
|
||||
columns={columns}
|
||||
dataSource={pageRows}
|
||||
pagination={{
|
||||
current: page + 1,
|
||||
pageSize: PAGE_SIZE,
|
||||
total: filtered.length,
|
||||
showSizeChanger: false,
|
||||
onChange: (p) => setPage(p - 1),
|
||||
}}
|
||||
onRow={(r) => ({
|
||||
onClick: () => setSelectedId(r.id),
|
||||
className: selectedId === r.id ? 'row-selected' : undefined,
|
||||
})}
|
||||
scroll={{ x: 1400 }}
|
||||
locale={{ emptyText: '영업점이 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<DraggableModal
|
||||
title="영업점 등록"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={createMut.isPending}
|
||||
okText="등록"
|
||||
width={860}
|
||||
destroyOnClose
|
||||
className="order-form-modal branch-form-modal"
|
||||
styles={{ body: { maxHeight: 'calc(92vh - 56px)', overflowY: 'auto' } }}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
className="order-form-embedded-inner order-form-page reception-apply-form"
|
||||
onFinish={(v) => createMut.mutate(v)}
|
||||
initialValues={{ level: 1, isTax: 1 }}
|
||||
>
|
||||
<FormSection title="계정 정보">
|
||||
<FormGrid>
|
||||
<FormField label="아이디*">
|
||||
<Form.Item name="userid" noStyle rules={[{ required: true, message: '아이디를 입력하세요' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="패스워드*">
|
||||
<Form.Item name="password" noStyle rules={[{ required: true, message: '패스워드를 입력하세요' }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</FormSection>
|
||||
|
||||
<FormSection title="기본 정보">
|
||||
<FormGrid>
|
||||
<FormField label="상호(영업점명)*">
|
||||
<Form.Item name="officeName" noStyle rules={[{ required: true, message: '영업점명을 입력하세요' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="대표자명*">
|
||||
<Form.Item name="name" noStyle rules={[{ required: true, message: '대표자명을 입력하세요' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="전화">
|
||||
<Form.Item name="tel" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="휴대폰*">
|
||||
<Form.Item name="phone" noStyle rules={[{ required: true, message: '휴대폰을 입력하세요' }]}>
|
||||
<Input placeholder="010-0000-0000" />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="이메일*" full>
|
||||
<Form.Item name="email" noStyle rules={[{ required: true, type: 'email', message: '이메일을 입력하세요' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</FormSection>
|
||||
|
||||
<FormSection title="주소">
|
||||
<FormGrid>
|
||||
<FormField label="우편번호">
|
||||
<Form.Item name="zipcode" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="주소">
|
||||
<Form.Item name="address" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
<FormField label="상세주소" full>
|
||||
<Form.Item name="addressDetail" noStyle><Input /></Form.Item>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</FormSection>
|
||||
|
||||
<FormSection title="설정">
|
||||
<FormGrid>
|
||||
<FormField label="레벨">
|
||||
<Form.Item name="level" noStyle>
|
||||
<Select options={[
|
||||
{ value: 1, label: '영업점' },
|
||||
{ value: 5, label: '센터' },
|
||||
{ value: 9, label: '관리자' },
|
||||
{ value: 10, label: '최고관리자' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="영업점 그룹">
|
||||
<Form.Item name="branchGroupId" noStyle>
|
||||
<Select allowClear placeholder="없음" options={groups.map((g) => ({ value: g.id, label: g.name }))} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
<FormField label="세금계산서" full>
|
||||
<Form.Item name="isTax" noStyle>
|
||||
<Select options={[
|
||||
{ value: 1, label: '세금계산서발행' },
|
||||
{ value: 0, label: '종합소득세공제' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
</FormField>
|
||||
</FormGrid>
|
||||
</FormSection>
|
||||
</Form>
|
||||
</DraggableModal>
|
||||
|
||||
<DraggableModal
|
||||
title="영업점 수정"
|
||||
open={editOpen}
|
||||
onCancel={() => setEditOpen(false)}
|
||||
onOk={() => editForm.submit()}
|
||||
confirmLoading={updateMut.isPending}
|
||||
okText="수정"
|
||||
width={920}
|
||||
destroyOnClose
|
||||
className="order-form-modal branch-form-modal"
|
||||
styles={{ body: { maxHeight: 'calc(92vh - 56px)', overflowY: 'auto' } }}
|
||||
>
|
||||
<Form
|
||||
form={editForm}
|
||||
layout="vertical"
|
||||
className="order-form-embedded-inner order-form-page reception-apply-form"
|
||||
onFinish={(values) => {
|
||||
const phones: string[] = values.notifyPhones || []
|
||||
updateMut.mutate({
|
||||
...values,
|
||||
isPersonal: values.isPersonal,
|
||||
acceptPhone: values.acceptPhone ? 1 : 0,
|
||||
acceptNateon: values.acceptNateon ? 1 : 0,
|
||||
notifyPhones: phones,
|
||||
})
|
||||
}}
|
||||
>
|
||||
<EditBranchFormBody groups={groups} centers={centers} />
|
||||
</Form>
|
||||
</DraggableModal>
|
||||
|
||||
<BranchFeeFlowModals
|
||||
open={feeOpen}
|
||||
branchId={feeBranch?.id ?? null}
|
||||
branchName={feeBranch?.officeName ?? ''}
|
||||
onClose={() => { setFeeOpen(false); setFeeBranch(null) }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,898 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Button, Checkbox, DatePicker, Drawer, Input, InputNumber, Radio, Select, Space, Table, message } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import PreparedDetailEditor from './PreparedDetailEditor'
|
||||
import { isFeeRowClickTarget, openFeeOrderPopup } from './FeeOrderPopup'
|
||||
|
||||
export type Kind = 'branch' | 'center'
|
||||
|
||||
export type Summary = {
|
||||
entityKey: string
|
||||
title: string
|
||||
sub: string
|
||||
contact: string
|
||||
tax: string
|
||||
group: string
|
||||
sido: string
|
||||
plusCnt: number
|
||||
minusCnt: number
|
||||
}
|
||||
|
||||
type Line = {
|
||||
id: number
|
||||
lineId: number
|
||||
type: string
|
||||
typeDesc: string | null
|
||||
cost: number
|
||||
event: number
|
||||
agreement: number
|
||||
local: number
|
||||
gift: number
|
||||
voc: number
|
||||
etc: number
|
||||
total: number
|
||||
memo: string | null
|
||||
status: string
|
||||
contractNumber: string
|
||||
customerName?: string
|
||||
}
|
||||
|
||||
export type Group = {
|
||||
id: number
|
||||
entityKey?: string
|
||||
plus: number
|
||||
minus: number
|
||||
total: number
|
||||
rate: number
|
||||
isTax: number
|
||||
taxDate: string | null
|
||||
completed: string | null
|
||||
transferred: string | null
|
||||
bank: string | null
|
||||
accountNumber: string | null
|
||||
accountName: string | null
|
||||
memo: string | null
|
||||
plusCnt: number
|
||||
minusCnt: number
|
||||
branchName?: string | null
|
||||
officeName?: string | null
|
||||
centerName?: string | null
|
||||
}
|
||||
|
||||
const TABS = [
|
||||
{ key: 'prepared', label: '가정산 리스트' },
|
||||
{ key: 'waiting', label: '정산대기 리스트' },
|
||||
{ key: 'completed', label: '정산완료 리스트' },
|
||||
{ key: 'transferred', label: '송금(환수)완료 리스트' },
|
||||
] as const
|
||||
type TabKey = (typeof TABS)[number]['key']
|
||||
|
||||
const won = (n: number) => (n ?? 0).toLocaleString('ko-KR')
|
||||
const today = () => dayjs().format('YYYY-MM-DD')
|
||||
|
||||
/** 정산 상세 Drawer — 좌측패널까지 덮는 전체화면 */
|
||||
const fullscreenDrawerProps = {
|
||||
width: '100%' as const,
|
||||
placement: 'right' as const,
|
||||
rootClassName: 'calc-fullscreen-drawer-root',
|
||||
getContainer: () => document.body,
|
||||
zIndex: 1200,
|
||||
destroyOnClose: true,
|
||||
closable: false,
|
||||
styles: { body: { paddingTop: 8 } },
|
||||
}
|
||||
|
||||
function CalcDetailDrawerTitle({
|
||||
label,
|
||||
onBack,
|
||||
}: {
|
||||
label: string
|
||||
onBack: () => void
|
||||
}) {
|
||||
return (
|
||||
<div className="calc-drawer-titlebar">
|
||||
<div className="calc-drawer-titlebar-left">
|
||||
<Button type="default" size="small" className="calc-drawer-back-btn" onClick={onBack}>
|
||||
목록으로
|
||||
</Button>
|
||||
<span className="calc-drawer-title-text">{label}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CalcDetailDrawerExtra({ onClose }: { onClose: () => void }) {
|
||||
return (
|
||||
<Button type="default" size="small" className="calc-drawer-close-btn" onClick={onClose}>
|
||||
닫기
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
function cfg(kind: Kind) {
|
||||
const base = kind === 'branch'
|
||||
? `${apiPrefix()}/homes/calculate`
|
||||
: `${apiPrefix()}/homes/center-calculate`
|
||||
return {
|
||||
base,
|
||||
normSummary: (r: Record<string, unknown>): Summary => ({
|
||||
entityKey: String(kind === 'branch' ? (r.entityKey ?? r.branchId) : (r.entityKey ?? r.centerId)),
|
||||
title: String(kind === 'branch' ? (r.officeName ?? r.branchName ?? '') : (r.name ?? r.centerName ?? '')),
|
||||
sub: String(kind === 'branch' ? (r.name ?? '') : (r.manager ?? '')),
|
||||
contact: String(r.phone || r.tel || ''),
|
||||
tax: kind === 'branch' ? (r.isTax === 1 ? '세금계산서발행' : '종합소득세공제') : '세금계산서발행',
|
||||
group: String(kind === 'branch' ? (r.branchGroupName || '') : ''),
|
||||
sido: String(kind === 'branch' ? (r.sido || '') : ''),
|
||||
plusCnt: Number(r.plusCnt || 0),
|
||||
minusCnt: Number(r.minusCnt || 0),
|
||||
}),
|
||||
normLine: (r: Record<string, unknown>): Line => ({
|
||||
id: Number(r.id),
|
||||
lineId: Number(kind === 'branch' ? (r.orderId ?? r.receptionId) : (r.orderCenterId ?? r.receptionId)),
|
||||
type: String(r.type || ''),
|
||||
typeDesc: r.typeDesc == null ? null : String(r.typeDesc),
|
||||
cost: Number(r.cost || 0),
|
||||
event: Number(r.event || 0),
|
||||
agreement: Number(r.agreement || 0),
|
||||
local: Number(r.local || 0),
|
||||
gift: Number(r.gift || 0),
|
||||
voc: Number(r.voc || 0),
|
||||
etc: Number(r.etc || 0),
|
||||
total: Number(r.total || 0),
|
||||
memo: r.memo == null ? null : String(r.memo),
|
||||
status: String(r.status || ''),
|
||||
contractNumber: String(r.contractNumber || ''),
|
||||
customerName: r.customerName ? String(r.customerName) : undefined,
|
||||
}),
|
||||
normGroup: (r: Record<string, unknown>): Group => ({
|
||||
id: Number(r.id),
|
||||
entityKey: r.entityKey == null ? undefined : String(r.entityKey),
|
||||
plus: Number(r.plus || 0),
|
||||
minus: Number(r.minus || 0),
|
||||
total: Number(r.total || 0),
|
||||
rate: Number(r.rate || 0),
|
||||
isTax: Number(r.isTax ?? 1),
|
||||
taxDate: r.taxDate == null ? null : String(r.taxDate),
|
||||
completed: r.completed == null ? null : String(r.completed),
|
||||
transferred: r.transferred == null ? null : String(r.transferred),
|
||||
bank: r.bank == null ? null : String(r.bank),
|
||||
accountNumber: r.accountNumber == null ? null : String(r.accountNumber),
|
||||
accountName: r.accountName == null ? null : String(r.accountName),
|
||||
memo: r.memo == null ? null : String(r.memo),
|
||||
plusCnt: Number(r.plusCnt || 0),
|
||||
minusCnt: Number(r.minusCnt || 0),
|
||||
branchName: r.branchName == null ? null : String(r.branchName),
|
||||
officeName: r.officeName == null ? null : String(r.officeName),
|
||||
centerName: r.centerName == null ? null : String(r.centerName),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
export default function CalculatePage({ kind }: { kind: Kind }) {
|
||||
const [sp, setSp] = useSearchParams()
|
||||
const tabLabels = useMemo(() => {
|
||||
if (kind === 'center') {
|
||||
return TABS.map((t) => (t.key === 'transferred' ? { ...t, label: '입금완료 리스트' } : t))
|
||||
}
|
||||
return [...TABS]
|
||||
}, [kind])
|
||||
|
||||
const [tab, setTab] = useState<TabKey>(() => {
|
||||
const t = sp.get('tab') as TabKey
|
||||
return TABS.some((x) => x.key === t) ? t : 'prepared'
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const t = sp.get('tab') as TabKey
|
||||
if (t && TABS.some((x) => x.key === t)) {
|
||||
setTab(t)
|
||||
} else if (!sp.get('tab')) {
|
||||
const next = new URLSearchParams(sp)
|
||||
next.set('tab', 'prepared')
|
||||
setSp(next, { replace: true })
|
||||
}
|
||||
}, [sp, setSp])
|
||||
|
||||
const changeTab = (key: TabKey) => {
|
||||
setTab(key)
|
||||
const next = new URLSearchParams(sp)
|
||||
next.set('tab', key)
|
||||
setSp(next, { replace: true })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stock-page calculate-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>{kind === 'branch' ? '영업점 정산현황' : '입력처 정산현황'}</h2>
|
||||
<p>
|
||||
{kind === 'branch'
|
||||
? '가정산 → 정산대기 → 정산완료 → 송금(환수)완료 순으로 처리합니다.'
|
||||
: '가정산 → 정산대기 → 정산완료 → 입금완료 순으로 처리합니다.'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="toolbar" style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 16 }}>
|
||||
{tabLabels.map((t) => (
|
||||
<Button key={t.key} type={tab === t.key ? 'primary' : 'default'} onClick={() => changeTab(t.key)}>
|
||||
{t.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{tab === 'prepared' && <PreparedTab kind={kind} />}
|
||||
{tab === 'waiting' && <WaitingTab kind={kind} />}
|
||||
{tab === 'completed' && <CompletedTab kind={kind} />}
|
||||
{tab === 'transferred' && <TransferredTab kind={kind} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function PreparedTab({ kind }: { kind: Kind }) {
|
||||
const c = cfg(kind)
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['calc', kind, 'prepared'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Record<string, unknown>[]>>(`${c.base}/prepared`)
|
||||
return (unwrap(data) || []).map(c.normSummary)
|
||||
},
|
||||
})
|
||||
const [open, setOpen] = useState<Summary | null>(null)
|
||||
return (
|
||||
<>
|
||||
<SummaryTable rows={data} kind={kind} loading={isLoading} onOpen={setOpen} />
|
||||
<Drawer
|
||||
open={!!open}
|
||||
onClose={() => setOpen(null)}
|
||||
{...fullscreenDrawerProps}
|
||||
title={open ? <CalcDetailDrawerTitle label={`${open.title} — 가정산 상세리스트`} onBack={() => setOpen(null)} /> : null}
|
||||
extra={open ? <CalcDetailDrawerExtra onClose={() => setOpen(null)} /> : null}
|
||||
>
|
||||
{open && (
|
||||
<PreparedDetailEditor
|
||||
kind={kind}
|
||||
row={open}
|
||||
onDone={() => { setOpen(null); refetch() }}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function WaitingTab({ kind }: { kind: Kind }) {
|
||||
const c = cfg(kind)
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['calc', kind, 'waiting'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Record<string, unknown>[]>>(`${c.base}/waiting`)
|
||||
return (unwrap(data) || []).map(c.normSummary)
|
||||
},
|
||||
})
|
||||
const [open, setOpen] = useState<Summary | null>(null)
|
||||
return (
|
||||
<>
|
||||
<SummaryTable rows={data} kind={kind} loading={isLoading} onOpen={setOpen} />
|
||||
<Drawer
|
||||
open={!!open}
|
||||
onClose={() => setOpen(null)}
|
||||
{...fullscreenDrawerProps}
|
||||
title={open ? <CalcDetailDrawerTitle label={`${open.title} — 정산대기 상세리스트`} onBack={() => setOpen(null)} /> : null}
|
||||
extra={open ? <CalcDetailDrawerExtra onClose={() => setOpen(null)} /> : null}
|
||||
>
|
||||
{open && (
|
||||
<WaitingEditor
|
||||
kind={kind}
|
||||
row={open}
|
||||
onDone={() => { setOpen(null); refetch() }}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function WaitingEditor({ kind, row, onDone }: { kind: Kind; row: Summary; onDone: () => void }) {
|
||||
const c = cfg(kind)
|
||||
const qc = useQueryClient()
|
||||
const linesKey = ['calc', kind, 'waitingLines', row.entityKey]
|
||||
const { data } = useQuery({
|
||||
queryKey: linesKey,
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Record<string, unknown>[]>>(`${c.base}/waiting/lines`, {
|
||||
params: { entity: row.entityKey },
|
||||
})
|
||||
return (unwrap(data) || []).map(c.normLine)
|
||||
},
|
||||
})
|
||||
const { data: completedGroups } = useQuery({
|
||||
queryKey: ['calc', kind, 'completed', 'merge', row.entityKey],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Record<string, unknown>[]>>(`${c.base}/completed`, {
|
||||
params: { entity: row.entityKey },
|
||||
})
|
||||
return (unwrap(data) || []).map(c.normGroup)
|
||||
},
|
||||
})
|
||||
|
||||
const [edits, setEdits] = useState<Record<number, Line>>({})
|
||||
const lines = useMemo(() => (data ?? []).map((l) => edits[l.id] ?? l), [data, edits])
|
||||
const [plus, setPlus] = useState(0)
|
||||
const [minus, setMinus] = useState(0)
|
||||
const [completed, setCompleted] = useState(today())
|
||||
const [memo, setMemo] = useState('')
|
||||
const [mergeMode, setMergeMode] = useState<'new' | 'merge'>('new')
|
||||
const [mergeGroupId, setMergeGroupId] = useState<number | undefined>()
|
||||
const total = lines.reduce((a, l) => a + l.total, 0) + plus + minus
|
||||
|
||||
const patch = (id: number, field: keyof Line, value: number) => {
|
||||
setEdits((prev) => {
|
||||
const base = prev[id] ?? data!.find((d) => d.id === id)!
|
||||
const next = { ...base, [field]: value }
|
||||
next.total = next.cost + next.event + next.agreement + next.local + next.gift + next.voc + next.etc
|
||||
return { ...prev, [id]: next }
|
||||
})
|
||||
}
|
||||
|
||||
const saveLine = useMutation({
|
||||
mutationFn: async (l: Line) => {
|
||||
const { data } = await client.post(`${c.base}/cost/${l.id}`, {
|
||||
cost: l.cost, event: l.event, agreement: l.agreement, local: l.local,
|
||||
gift: l.gift, voc: l.voc, etc: l.etc, memo: l.memo,
|
||||
})
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => { setEdits({}); qc.invalidateQueries({ queryKey: linesKey }); message.success('저장됨') },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const revert = useMutation({
|
||||
mutationFn: async (lineId: number) => {
|
||||
const { data } = await client.post(`${c.base}/to-prepared`, { receptionIds: [lineId] })
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: linesKey })
|
||||
qc.invalidateQueries({ queryKey: ['calc', kind] })
|
||||
message.success('가정산으로 되돌렸습니다.')
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const submit = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (mergeMode === 'merge' && !mergeGroupId) throw new Error('결합할 기존 그룹을 선택하세요.')
|
||||
const { data } = await client.post(`${c.base}/toss-completed`, {
|
||||
entity: row.entityKey,
|
||||
plus, minus, completed, memo,
|
||||
mergeGroupId: mergeMode === 'merge' ? mergeGroupId : null,
|
||||
})
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => { message.success('정산완료 처리되었습니다.'); onDone() },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={lines}
|
||||
onRow={(r) => ({
|
||||
style: { cursor: 'pointer' },
|
||||
onClick: (e) => {
|
||||
if (!isFeeRowClickTarget(e.target)) return
|
||||
void openFeeOrderPopup(r.lineId, kind)
|
||||
},
|
||||
})}
|
||||
columns={[
|
||||
{ title: '접수', width: 90, render: (_: unknown, r: Line) => r.contractNumber || r.lineId },
|
||||
{ title: '고객', width: 90, dataIndex: 'customerName' },
|
||||
{ title: '구분', width: 70, render: (_: unknown, r: Line) => (r.type.endsWith('01') ? '정산' : (r.typeDesc || '환수')) },
|
||||
...(['cost', 'event', 'agreement', 'local', 'gift', 'voc', 'etc'] as const).map((f) => ({
|
||||
title: ({ cost: '단가', event: '이벤트', agreement: '약정', local: '지역', gift: '사은품', voc: 'VOC', etc: '기타' } as const)[f],
|
||||
width: 90,
|
||||
align: 'right' as const,
|
||||
render: (_: unknown, r: Line) => (
|
||||
<InputNumber
|
||||
size="small"
|
||||
value={r[f]}
|
||||
style={{ width: 80 }}
|
||||
className="calc-money-input"
|
||||
formatter={(v) => `${v ?? ''}`.replace(/\B(?=(\d{3})+(?!\d))/g, ',')}
|
||||
parser={(v) => Number((v ?? '').replace(/,/g, '') || 0) as unknown as 0}
|
||||
onChange={(v) => patch(r.id, f, Number(v || 0))}
|
||||
/>
|
||||
),
|
||||
})),
|
||||
{ title: '합계', width: 100, align: 'right' as const, render: (_: unknown, r: Line) => <b>{won(r.total)}</b> },
|
||||
{
|
||||
title: '작업',
|
||||
width: 150,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, r: Line) => (
|
||||
<Space size={4}>
|
||||
<Button size="small" type="primary" className="branch-row-btn" onClick={(e) => { e.stopPropagation(); saveLine.mutate(r) }}>저장</Button>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
danger
|
||||
className="branch-row-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (window.confirm('이 접수를 가정산으로 되돌립니다.')) revert.mutate(r.lineId)
|
||||
}}
|
||||
>
|
||||
되돌리기
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '수수료현황',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, r: Line) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="branch-row-btn fee-status-row-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void openFeeOrderPopup(r.lineId, kind)
|
||||
}}
|
||||
>
|
||||
수수료현황
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
<Space wrap style={{ marginTop: 16 }} size={12}>
|
||||
<span>기타정산 <InputNumber value={plus} onChange={(v) => setPlus(Number(v || 0))} /></span>
|
||||
<span>기타차감 <InputNumber value={minus} onChange={(v) => setMinus(Number(v || 0))} /></span>
|
||||
<span>정산일자 <DatePicker value={dayjs(completed)} onChange={(d) => setCompleted(d?.format('YYYY-MM-DD') || today())} /></span>
|
||||
<span>메모 <Input value={memo} onChange={(e) => setMemo(e.target.value)} style={{ width: 180 }} /></span>
|
||||
</Space>
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<Radio.Group value={mergeMode} onChange={(e) => { setMergeMode(e.target.value); setMergeGroupId(undefined) }}>
|
||||
<Radio value="new">새 그룹으로 정산완료</Radio>
|
||||
<Radio value="merge" disabled={!completedGroups?.length}>기존 그룹에 결합</Radio>
|
||||
</Radio.Group>
|
||||
{mergeMode === 'merge' && (
|
||||
<Select
|
||||
style={{ minWidth: 360, marginLeft: 12 }}
|
||||
placeholder="결합할 그룹 선택"
|
||||
value={mergeGroupId}
|
||||
onChange={setMergeGroupId}
|
||||
options={(completedGroups || []).map((g) => ({
|
||||
value: g.id,
|
||||
label: `#${g.id} 정산일 ${g.completed?.slice(0, 10) || '-'} · ${won(g.total)}원 · 정산 ${g.plusCnt}/환수 ${g.minusCnt}`,
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ marginTop: 16, display: 'flex', justifyContent: 'flex-end', gap: 12, alignItems: 'center' }}>
|
||||
<span>최종합계 <b>{won(total)}</b> 원</span>
|
||||
<Button type="primary" loading={submit.isPending} onClick={() => submit.mutate()} disabled={!lines.length}>
|
||||
정산완료 처리
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CompletedTab({ kind }: { kind: Kind }) {
|
||||
return <GroupTable kind={kind} mode="completed" />
|
||||
}
|
||||
|
||||
function TransferredTab({ kind }: { kind: Kind }) {
|
||||
return <GroupTable kind={kind} mode="transferred" />
|
||||
}
|
||||
|
||||
function GroupTable({ kind, mode }: { kind: Kind; mode: 'completed' | 'transferred' }) {
|
||||
const c = cfg(kind)
|
||||
const endpoint = mode === 'completed' ? 'completed' : 'transferred'
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['calc', kind, endpoint],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Record<string, unknown>[]>>(`${c.base}/${endpoint}`)
|
||||
return (unwrap(data) || []).map(c.normGroup)
|
||||
},
|
||||
})
|
||||
const [open, setOpen] = useState<Group | null>(null)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={isLoading}
|
||||
dataSource={data || []}
|
||||
pagination={{ pageSize: 20 }}
|
||||
onRow={(r) => ({ onClick: () => setOpen(r), style: { cursor: 'pointer' } })}
|
||||
columns={[
|
||||
{ title: '그룹#', dataIndex: 'id', width: 80 },
|
||||
{
|
||||
title: kind === 'branch' ? '영업점' : '입력처',
|
||||
render: (_: unknown, r: Group) => r.officeName || r.branchName || r.centerName || '-',
|
||||
},
|
||||
{ title: '정산', width: 70, dataIndex: 'plusCnt', align: 'right' as const },
|
||||
{ title: '환수', width: 70, dataIndex: 'minusCnt', align: 'right' as const },
|
||||
{ title: '합계', width: 110, align: 'right' as const, render: (v: number) => won(v) },
|
||||
{ title: '완료일', width: 120, render: (_: unknown, r: Group) => r.completed?.slice(0, 10) || '-' },
|
||||
{
|
||||
title: kind === 'branch' ? '송금일' : '입금일',
|
||||
width: 120,
|
||||
render: (_: unknown, r: Group) => r.transferred?.slice(0, 10) || '-',
|
||||
},
|
||||
{ title: '메모', dataIndex: 'memo', ellipsis: true },
|
||||
{
|
||||
title: '상세보기',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, r: Group) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="branch-row-btn detail-view-row-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setOpen(r)
|
||||
}}
|
||||
>
|
||||
상세보기
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
scroll={{ x: 1100 }}
|
||||
/>
|
||||
<Drawer
|
||||
open={!!open}
|
||||
onClose={() => setOpen(null)}
|
||||
{...fullscreenDrawerProps}
|
||||
title={open ? (
|
||||
<CalcDetailDrawerTitle
|
||||
label={
|
||||
mode === 'completed'
|
||||
? `정산 #${open.id} — 정산완료 상세리스트`
|
||||
: `정산 #${open.id} — ${kind === 'branch' ? '송금(환수)완료' : '입금완료'} 상세리스트`
|
||||
}
|
||||
onBack={() => setOpen(null)}
|
||||
/>
|
||||
) : null}
|
||||
extra={open ? <CalcDetailDrawerExtra onClose={() => setOpen(null)} /> : null}
|
||||
>
|
||||
{open && (
|
||||
<TransferEditor
|
||||
kind={kind}
|
||||
group={open}
|
||||
editMode={mode}
|
||||
onDone={() => { setOpen(null); refetch() }}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function TransferEditor({
|
||||
kind, group, editMode, onDone,
|
||||
}: {
|
||||
kind: Kind
|
||||
group: Group
|
||||
editMode: 'completed' | 'transferred'
|
||||
onDone: () => void
|
||||
}) {
|
||||
const c = cfg(kind)
|
||||
const isTransferredEdit = editMode === 'transferred' || !!group.transferred
|
||||
const { data: lines } = useQuery({
|
||||
queryKey: ['calc', kind, 'groupLines', group.id],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Record<string, unknown>[]>>(`${c.base}/group/${group.id}/lines`)
|
||||
return (unwrap(data) || []).map(c.normLine)
|
||||
},
|
||||
})
|
||||
|
||||
const [ratePercent, setRatePercent] = useState(group.isTax === 1 ? 10 : -3.3)
|
||||
const [taxBill, setTaxBill] = useState(group.isTax === 1)
|
||||
const [taxDate, setTaxDate] = useState(group.taxDate?.slice(0, 10) || today())
|
||||
const [transferred, setTransferred] = useState(group.transferred?.slice(0, 10) || today())
|
||||
const [plus, setPlus] = useState(group.plus ?? 0)
|
||||
const [minus, setMinus] = useState(group.minus ?? 0)
|
||||
const [completed, setCompleted] = useState(group.completed?.slice(0, 10) || today())
|
||||
const [memo, setMemo] = useState(group.memo || '')
|
||||
const [bank, setBank] = useState(group.bank || '')
|
||||
const [accountNumber, setAccountNumber] = useState(group.accountNumber || '')
|
||||
const [accountName, setAccountName] = useState(group.accountName || '')
|
||||
const tax = Math.round(group.total * (ratePercent / 100))
|
||||
const payout = group.total + tax
|
||||
|
||||
const submit = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await client.post(`${c.base}/group/${group.id}/toss-transferred`, {
|
||||
rate: ratePercent, taxBill, taxDate, transferred,
|
||||
bank, accountNumber, accountName, memo,
|
||||
})
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(kind === 'branch' ? '송금완료 처리되었습니다.' : '입금완료 처리되었습니다.')
|
||||
onDone()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const update = useMutation({
|
||||
mutationFn: async () => {
|
||||
const path = isTransferredEdit
|
||||
? `${c.base}/group/${group.id}/transferred`
|
||||
: `${c.base}/group/${group.id}`
|
||||
const body = isTransferredEdit
|
||||
? { plus, minus, completed, transferred, rate: ratePercent, taxBill, taxDate, memo, bank, accountNumber, accountName }
|
||||
: { plus, minus, completed, memo }
|
||||
const { data } = await client.post(path, body)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => { message.success('수정 저장되었습니다.'); onDone() },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const cancel = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await client.post(`${c.base}/group/${group.id}/cancel`)
|
||||
return unwrap(data)
|
||||
},
|
||||
onSuccess: () => { message.success('정산대기로 되돌렸습니다.'); onDone() },
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={lines || []}
|
||||
onRow={(r) => ({
|
||||
style: { cursor: 'pointer' },
|
||||
onClick: (e) => {
|
||||
if (!isFeeRowClickTarget(e.target)) return
|
||||
void openFeeOrderPopup(r.lineId, kind)
|
||||
},
|
||||
})}
|
||||
columns={[
|
||||
{ title: '접수', width: 90, render: (_: unknown, r: Line) => r.contractNumber || r.lineId },
|
||||
{ title: '고객', dataIndex: 'customerName', width: 90 },
|
||||
{ title: '구분', width: 70, render: (_: unknown, r: Line) => (r.type.endsWith('01') ? '정산' : (r.typeDesc || '환수')) },
|
||||
{ title: '단가', dataIndex: 'cost', width: 80, align: 'right' as const, render: won },
|
||||
{ title: '이벤트', dataIndex: 'event', width: 80, align: 'right' as const, render: won },
|
||||
{ title: '약정', dataIndex: 'agreement', width: 80, align: 'right' as const, render: won },
|
||||
{ title: '지역', dataIndex: 'local', width: 80, align: 'right' as const, render: won },
|
||||
{ title: '사은품', dataIndex: 'gift', width: 80, align: 'right' as const, render: won },
|
||||
{ title: 'VOC', dataIndex: 'voc', width: 80, align: 'right' as const, render: won },
|
||||
{ title: '기타', dataIndex: 'etc', width: 80, align: 'right' as const, render: won },
|
||||
{ title: '합계', dataIndex: 'total', width: 90, align: 'right' as const, render: (v: number) => <b>{won(v)}</b> },
|
||||
{
|
||||
title: '수수료현황',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, r: Line) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="branch-row-btn fee-status-row-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void openFeeOrderPopup(r.lineId, kind)
|
||||
}}
|
||||
>
|
||||
수수료현황
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
scroll={{ x: 1100 }}
|
||||
/>
|
||||
|
||||
<Space wrap style={{ marginTop: 16 }} size={12}>
|
||||
<b>{isTransferredEdit ? (kind === 'branch' ? '송금완료 수정' : '입금완료 수정') : '정산완료 수정'}</b>
|
||||
<span>기타정산 <InputNumber value={plus} onChange={(v) => setPlus(Number(v || 0))} /></span>
|
||||
<span>기타차감 <InputNumber value={minus} onChange={(v) => {
|
||||
const n = Number(v || 0)
|
||||
setMinus(n > 0 ? -Math.abs(n) : n)
|
||||
}} /></span>
|
||||
<span>정산일자 <DatePicker value={dayjs(completed)} onChange={(d) => setCompleted(d?.format('YYYY-MM-DD') || today())} /></span>
|
||||
{isTransferredEdit && (
|
||||
<span>{kind === 'branch' ? '송금일' : '입금일'}{' '}
|
||||
<DatePicker value={dayjs(transferred)} onChange={(d) => setTransferred(d?.format('YYYY-MM-DD') || today())} />
|
||||
</span>
|
||||
)}
|
||||
<span>메모 <Input value={memo} onChange={(e) => setMemo(e.target.value)} style={{ width: 160 }} /></span>
|
||||
{kind === 'branch' && isTransferredEdit && (
|
||||
<>
|
||||
<span>은행 <Input value={bank} onChange={(e) => setBank(e.target.value)} style={{ width: 80 }} /></span>
|
||||
<span>계좌 <Input value={accountNumber} onChange={(e) => setAccountNumber(e.target.value)} style={{ width: 120 }} /></span>
|
||||
<span>예금주 <Input value={accountName} onChange={(e) => setAccountName(e.target.value)} style={{ width: 80 }} /></span>
|
||||
</>
|
||||
)}
|
||||
<Button type="primary" className="branch-row-btn" onClick={() => update.mutate()}>수정 저장</Button>
|
||||
</Space>
|
||||
|
||||
{!isTransferredEdit && (
|
||||
<Space wrap style={{ marginTop: 16 }} size={12}>
|
||||
<Checkbox checked={taxBill} onChange={(e) => {
|
||||
setTaxBill(e.target.checked)
|
||||
setRatePercent(e.target.checked ? 10 : -3.3)
|
||||
}}>세금계산서 발행</Checkbox>
|
||||
<span>세율(%) <InputNumber step={0.1} value={ratePercent} onChange={(v) => setRatePercent(Number(v || 0))} /></span>
|
||||
{taxBill && (
|
||||
<span>발행일 <DatePicker value={dayjs(taxDate)} onChange={(d) => setTaxDate(d?.format('YYYY-MM-DD') || today())} /></span>
|
||||
)}
|
||||
<span>{kind === 'branch' ? '송금일' : '입금일'}{' '}
|
||||
<DatePicker value={dayjs(transferred)} onChange={(d) => setTransferred(d?.format('YYYY-MM-DD') || today())} />
|
||||
</span>
|
||||
<span style={{ marginLeft: 8 }}>정산합계 {won(group.total)} / 세액 {won(tax)} / 지급액 <b>{won(payout)}</b></span>
|
||||
<Button type="primary" className="branch-row-btn" loading={submit.isPending} onClick={() => submit.mutate()}>
|
||||
{kind === 'branch' ? '송금완료' : '입금완료'}
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
className="branch-row-btn"
|
||||
onClick={() => {
|
||||
if (window.confirm('정산완료를 취소하고 정산대기로 되돌립니다.')) cancel.mutate()
|
||||
}}
|
||||
>
|
||||
정산취소
|
||||
</Button>
|
||||
</Space>
|
||||
)}
|
||||
|
||||
{isTransferredEdit && (
|
||||
<Space wrap style={{ marginTop: 16 }} size={12}>
|
||||
<Checkbox checked={taxBill} onChange={(e) => {
|
||||
setTaxBill(e.target.checked)
|
||||
setRatePercent(e.target.checked ? 10 : -3.3)
|
||||
}}>세금계산서 발행</Checkbox>
|
||||
<span>세율(%) <InputNumber step={0.1} value={ratePercent} onChange={(v) => setRatePercent(Number(v || 0))} /></span>
|
||||
{taxBill && (
|
||||
<span>발행일 <DatePicker value={dayjs(taxDate)} onChange={(d) => setTaxDate(d?.format('YYYY-MM-DD') || today())} /></span>
|
||||
)}
|
||||
<span>정산합계 {won(group.total)} / 세액 {won(tax)} / 지급액 <b>{won(payout)}</b></span>
|
||||
</Space>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SummaryTable({
|
||||
rows, kind, loading, onOpen,
|
||||
}: {
|
||||
rows?: Summary[]
|
||||
kind: Kind
|
||||
loading?: boolean
|
||||
onOpen: (row: Summary) => void
|
||||
}) {
|
||||
const [draft, setDraft] = useState({ group: '', title: '', sub: '', contact: '', sido: '', tax: '' })
|
||||
const [applied, setApplied] = useState(draft)
|
||||
const groups = useMemo(() => Array.from(new Set((rows ?? []).map((r) => r.group).filter(Boolean))).sort(), [rows])
|
||||
const sidos = useMemo(() => Array.from(new Set((rows ?? []).map((r) => r.sido).filter(Boolean))).sort(), [rows])
|
||||
const inc = (v: string | undefined, q: string) => (v ?? '').toLowerCase().includes(q.toLowerCase())
|
||||
const filtered = (rows ?? []).filter((b) =>
|
||||
(!applied.group || b.group === applied.group)
|
||||
&& (!applied.title || inc(b.title, applied.title))
|
||||
&& (!applied.sub || inc(b.sub, applied.sub))
|
||||
&& (!applied.contact || inc(b.contact, applied.contact))
|
||||
&& (!applied.sido || b.sido === applied.sido)
|
||||
&& (!applied.tax || b.tax === applied.tax))
|
||||
|
||||
const detailCol = {
|
||||
title: '상세보기',
|
||||
width: 100,
|
||||
align: 'center' as const,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, r: Summary) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="branch-row-btn detail-view-row-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
onOpen(r)
|
||||
}}
|
||||
>
|
||||
상세보기
|
||||
</Button>
|
||||
),
|
||||
}
|
||||
|
||||
const branchCols = [
|
||||
{ title: '그룹명', dataIndex: 'group', width: 120 },
|
||||
{ title: '영업점명', dataIndex: 'title' },
|
||||
{ title: '이름', dataIndex: 'sub', width: 100 },
|
||||
{ title: '연락처', dataIndex: 'contact', width: 130 },
|
||||
{ title: '지역', dataIndex: 'sido', width: 90 },
|
||||
{ title: '세금계산서', dataIndex: 'tax', width: 130 },
|
||||
{
|
||||
title: '정산 / 환수',
|
||||
width: 110,
|
||||
align: 'right' as const,
|
||||
render: (_: unknown, r: Summary) => (
|
||||
<b>{(r.plusCnt ?? 0).toLocaleString('ko-KR')} / {(r.minusCnt ?? 0).toLocaleString('ko-KR')}</b>
|
||||
),
|
||||
},
|
||||
detailCol,
|
||||
]
|
||||
|
||||
const centerCols = [
|
||||
{ title: '입력처명', dataIndex: 'title' },
|
||||
{ title: '이름', dataIndex: 'sub', width: 100 },
|
||||
{ title: '연락처', dataIndex: 'contact', width: 130 },
|
||||
{
|
||||
title: '정산 / 환수',
|
||||
width: 110,
|
||||
align: 'right' as const,
|
||||
render: (_: unknown, r: Summary) => (
|
||||
<b>{(r.plusCnt ?? 0).toLocaleString('ko-KR')} / {(r.minusCnt ?? 0).toLocaleString('ko-KR')}</b>
|
||||
),
|
||||
},
|
||||
detailCol,
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="summary-table-wrap">
|
||||
<Space wrap style={{ marginBottom: 12 }}>
|
||||
{kind === 'branch' && (
|
||||
<>
|
||||
<Select allowClear placeholder="그룹" style={{ width: 140 }} value={draft.group || undefined}
|
||||
onChange={(v) => setDraft((p) => ({ ...p, group: v || '' }))}
|
||||
options={groups.map((g) => ({ value: g, label: g }))} />
|
||||
<Input placeholder="영업점명" value={draft.title} onChange={(e) => setDraft((p) => ({ ...p, title: e.target.value }))} style={{ width: 140 }} />
|
||||
<Input placeholder="이름" value={draft.sub} onChange={(e) => setDraft((p) => ({ ...p, sub: e.target.value }))} style={{ width: 100 }} />
|
||||
<Input placeholder="연락처" value={draft.contact} onChange={(e) => setDraft((p) => ({ ...p, contact: e.target.value }))} style={{ width: 120 }} />
|
||||
<Select allowClear placeholder="지역" style={{ width: 110 }} value={draft.sido || undefined}
|
||||
onChange={(v) => setDraft((p) => ({ ...p, sido: v || '' }))}
|
||||
options={sidos.map((s) => ({ value: s, label: s }))} />
|
||||
<Select allowClear placeholder="세금계산서" style={{ width: 150 }} value={draft.tax || undefined}
|
||||
onChange={(v) => setDraft((p) => ({ ...p, tax: v || '' }))}
|
||||
options={[
|
||||
{ value: '세금계산서발행', label: '세금계산서발행' },
|
||||
{ value: '종합소득세공제', label: '종합소득세공제' },
|
||||
]} />
|
||||
</>
|
||||
)}
|
||||
{kind === 'center' && (
|
||||
<>
|
||||
<Input placeholder="입력처명" value={draft.title} onChange={(e) => setDraft((p) => ({ ...p, title: e.target.value }))} style={{ width: 160 }} />
|
||||
<Input placeholder="이름" value={draft.sub} onChange={(e) => setDraft((p) => ({ ...p, sub: e.target.value }))} style={{ width: 100 }} />
|
||||
<Input placeholder="연락처" value={draft.contact} onChange={(e) => setDraft((p) => ({ ...p, contact: e.target.value }))} style={{ width: 120 }} />
|
||||
</>
|
||||
)}
|
||||
<Button type="primary" onClick={() => setApplied({ ...draft })}>검색</Button>
|
||||
</Space>
|
||||
<p className="muted" style={{ marginBottom: 8, fontSize: 12 }}>행 클릭 또는 상세보기 버튼으로 상세리스트가 열립니다.</p>
|
||||
<Table
|
||||
rowKey="entityKey"
|
||||
size="small"
|
||||
loading={loading}
|
||||
dataSource={filtered}
|
||||
pagination={{ pageSize: 30 }}
|
||||
onRow={(r) => ({ onClick: () => onOpen(r), style: { cursor: 'pointer' } })}
|
||||
columns={kind === 'branch' ? branchCols : centerCols}
|
||||
scroll={{ x: kind === 'branch' ? 1100 : 700 }}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -448,7 +448,7 @@ export default function ContractBalancePage() {
|
||||
</Form.Item>
|
||||
<div className="balance-modal-footer">
|
||||
<Button className="balance-submit-btn" type="primary" htmlType="submit" loading={save.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
|
||||
@@ -424,7 +424,7 @@ export default function ContractWritePage() {
|
||||
|
||||
<div className="open-actions">
|
||||
<Button onClick={() => navigate(back)}>목록</Button>
|
||||
<Button type="primary" htmlType="submit" loading={save.isPending}>{id ? '수정합니다' : '등록'}</Button>
|
||||
<Button type="primary" htmlType="submit" loading={save.isPending}>{id ? '수정' : '등록'}</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
|
||||
@@ -660,7 +660,7 @@ export default function CustomerPage() {
|
||||
</Form.Item>
|
||||
<div className="balance-modal-footer">
|
||||
<Button className="customer-submit-btn" type="primary" htmlType="submit" loading={save.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
|
||||
@@ -276,7 +276,7 @@ export default function DeliveryPage() {
|
||||
</Form.Item>
|
||||
<div className="sms-send-footer">
|
||||
<Button type="primary" htmlType="submit" loading={saveMut.isPending} icon={<EditOutlined />}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setOpen(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,178 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { Button, ConfigProvider, Table, theme as antTheme, message } from 'antd'
|
||||
import koKR from 'antd/locale/ko_KR'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import DraggableModal from '../../components/DraggableModal'
|
||||
|
||||
export type FeeOrderView = {
|
||||
kind: string
|
||||
type: string
|
||||
feeId: number
|
||||
entityName: string
|
||||
ispName: string
|
||||
startDate: string
|
||||
memo?: string | null
|
||||
products: { productType: string; productName: string; fee: number }[]
|
||||
}
|
||||
|
||||
const PRODUCT_TYPE: Record<string, string> = {
|
||||
e01: '인터넷', e02: '전화', e03: 'TV', e04: '렌탈',
|
||||
}
|
||||
|
||||
/** 정산 상세 Drawer(zIndex 1200) 위에 표시 */
|
||||
const FEE_POPUP_Z = 2200
|
||||
|
||||
const won = (n: number) => `${(n ?? 0).toLocaleString('ko-KR')} 원`
|
||||
|
||||
let activeRoot: Root | null = null
|
||||
let activeHost: HTMLDivElement | null = null
|
||||
|
||||
function unmountFeePopup() {
|
||||
if (activeRoot) {
|
||||
try {
|
||||
activeRoot.unmount()
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
activeRoot = null
|
||||
}
|
||||
if (activeHost) {
|
||||
activeHost.remove()
|
||||
activeHost = null
|
||||
}
|
||||
}
|
||||
|
||||
function mountFeePopup(node: ReactNode) {
|
||||
unmountFeePopup()
|
||||
const host = document.createElement('div')
|
||||
host.className = 'fee-order-popup-host'
|
||||
document.body.appendChild(host)
|
||||
activeHost = host
|
||||
const root = createRoot(host)
|
||||
activeRoot = root
|
||||
root.render(
|
||||
<ConfigProvider locale={koKR} theme={{ algorithm: antTheme.defaultAlgorithm }}>
|
||||
{node}
|
||||
</ConfigProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* billing_imeere branch-group-fee/order · center-fee/order 대응.
|
||||
* 수수료 있으면 현황 모달, 없으면 "적용가능한 수수료가 없습니다." 알림.
|
||||
*/
|
||||
export async function openFeeOrderPopup(receptionId: number, kind: 'branch' | 'center') {
|
||||
if (!receptionId) {
|
||||
showAlertModal('접수 정보를 확인할 수 없습니다.')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const { data } = await client.get<ApiResponse<FeeOrderView>>(
|
||||
`${apiPrefix()}/homes/fee/order/${receptionId}`,
|
||||
{ params: { kind } },
|
||||
)
|
||||
const view = unwrap(data)
|
||||
showFeeStatusModal(view, kind)
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : '적용가능한 수수료가 없습니다.'
|
||||
showAlertModal(msg || '적용가능한 수수료가 없습니다.')
|
||||
}
|
||||
}
|
||||
|
||||
function showAlertModal(content: string) {
|
||||
const close = () => unmountFeePopup()
|
||||
mountFeePopup(
|
||||
<DraggableModal
|
||||
open
|
||||
title="수수료 현황"
|
||||
centered
|
||||
zIndex={FEE_POPUP_Z}
|
||||
onCancel={close}
|
||||
className="calc-fee-alert-modal"
|
||||
footer={(
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<Button type="primary" onClick={close}>확인</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div>{content}</div>
|
||||
</DraggableModal>,
|
||||
)
|
||||
}
|
||||
|
||||
function showFeeStatusModal(view: FeeOrderView, kind: 'branch' | 'center') {
|
||||
const entityLabel = kind === 'center' ? '입력처' : '영업점'
|
||||
const close = () => unmountFeePopup()
|
||||
|
||||
mountFeePopup(
|
||||
<DraggableModal
|
||||
open
|
||||
title="수수료 현황"
|
||||
width={720}
|
||||
centered
|
||||
zIndex={FEE_POPUP_Z}
|
||||
onCancel={close}
|
||||
className="calc-fee-status-modal"
|
||||
footer={(
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<Button type="primary" className="branch-row-btn" onClick={close}>닫기</Button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<div className="fee-order-popup">
|
||||
<table className="prg-table" style={{ marginBottom: 12 }}>
|
||||
<tbody>
|
||||
<tr><th style={{ width: 110 }}>{entityLabel}</th><td>{view.entityName}</td></tr>
|
||||
<tr><th>통신사</th><td>{view.ispName}</td></tr>
|
||||
<tr><th>적용날짜</th><td>{view.startDate}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<div style={{ marginBottom: 6, fontWeight: 600 }}>상품별 수수료</div>
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
rowKey={(r) => `${r.productType}-${r.productName}`}
|
||||
dataSource={view.products || []}
|
||||
locale={{ emptyText: '상품별 수수료가 없습니다.' }}
|
||||
columns={[
|
||||
{
|
||||
title: '상품종류',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
render: (_: unknown, r) => PRODUCT_TYPE[r.productType] || r.productType,
|
||||
},
|
||||
{ title: '상품명', dataIndex: 'productName', align: 'center' },
|
||||
{
|
||||
title: '적용금액',
|
||||
width: 140,
|
||||
align: 'right',
|
||||
render: (_: unknown, r) => won(r.fee),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
{view.memo ? (
|
||||
<table className="prg-table" style={{ marginTop: 12 }}>
|
||||
<tbody>
|
||||
<tr><th style={{ width: 110 }}>메모</th><td style={{ whiteSpace: 'pre-wrap' }}>{view.memo}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
) : null}
|
||||
</div>
|
||||
</DraggableModal>,
|
||||
)
|
||||
}
|
||||
|
||||
/** 행 클릭 시 입력/체크 영역은 무시 */
|
||||
export function isFeeRowClickTarget(target: EventTarget | null) {
|
||||
if (!(target instanceof HTMLElement)) return false
|
||||
return !target.closest(
|
||||
'input,button,a,textarea,.ant-checkbox,.ant-input,.ant-input-number,.ant-select,.ant-picker',
|
||||
)
|
||||
}
|
||||
|
||||
export function toastFeeError(err: unknown) {
|
||||
message.error(err instanceof Error ? err.message : '수수료 조회 실패')
|
||||
}
|
||||
@@ -331,7 +331,7 @@ export default function InCompanyPage() {
|
||||
</Form.Item>
|
||||
<div className="sms-send-footer">
|
||||
<Button type="primary" htmlType="submit" loading={saveMut.isPending} icon={<EditOutlined />}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setOpen(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
|
||||
@@ -252,7 +252,7 @@ export default function InternalBoardPage({ code, title }: Props) {
|
||||
</Form.Item>
|
||||
<div className="bbs-write-actions">
|
||||
<Button type="primary" htmlType="submit" loading={saveMut.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setOpen(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
|
||||
@@ -345,7 +345,7 @@ export default function OpenBalancePage() {
|
||||
</Form.Item>
|
||||
<div className="balance-modal-footer">
|
||||
<Button type="primary" htmlType="submit" loading={save.isPending} icon={<PlusOutlined />}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
|
||||
@@ -228,7 +228,7 @@ export default function OpenUnitCostPage() {
|
||||
<Radio.Group options={[{ value: '표준', label: '표준' }, { value: '공시/선택구분', label: '공시/선택구분' }]} />
|
||||
</Form.Item>
|
||||
<div className="unitcost-modal-footer">
|
||||
<Button type="primary" htmlType="submit" loading={save.isPending}>{editing ? '수정합니다' : '생성합니다'}</Button>
|
||||
<Button type="primary" htmlType="submit" loading={save.isPending}>{editing ? '수정' : '생성합니다'}</Button>
|
||||
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
</Form>
|
||||
|
||||
@@ -418,7 +418,7 @@ export default function OutCompanyPage() {
|
||||
</Form.Item>
|
||||
<div className="sms-send-footer">
|
||||
<Button type="primary" htmlType="submit" loading={saveMut.isPending} icon={<EditOutlined />}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setOpen(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
|
||||
@@ -414,7 +414,7 @@ export default function PendingCalendarPage() {
|
||||
</Form.Item>
|
||||
<div className="balance-modal-footer">
|
||||
<Button className="pending-submit-btn" type="primary" htmlType="submit" loading={save.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={closeModal}>취소</Button>
|
||||
</div>
|
||||
|
||||
@@ -495,7 +495,7 @@ export default function PendingPage() {
|
||||
</Form.Item>
|
||||
<div className="balance-modal-footer">
|
||||
<Button className="pending-submit-btn" type="primary" htmlType="submit" loading={save.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setCreating(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,865 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Button, Checkbox, DatePicker, Input, InputNumber, Radio, Select, Space, Table, message } from 'antd'
|
||||
import dayjs from 'dayjs'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { GIFT_PLACE, GIFT_STATUS, progressLabel } from './receptionStatuses'
|
||||
import type { Kind, Summary } from './CalculatePage'
|
||||
import { isFeeRowClickTarget, openFeeOrderPopup } from './FeeOrderPopup'
|
||||
|
||||
const won = (n: number) => (n ?? 0).toLocaleString('ko-KR')
|
||||
|
||||
const moneyInputProps = {
|
||||
size: 'small' as const,
|
||||
style: { width: 76, textAlign: 'right' as const },
|
||||
formatter: (v: string | number | undefined) =>
|
||||
`${v ?? ''}`.replace(/\B(?=(\d{3})+(?!\d))/g, ','),
|
||||
parser: (v: string | undefined) => Number((v ?? '').replace(/,/g, '') || 0) as unknown as 0,
|
||||
}
|
||||
const today = () => dayjs().format('YYYY-MM-DD')
|
||||
|
||||
const SIDO_LIST = ['서울', '경기', '인천', '강원', '충북', '충남', '대전', '전북', '전남', '광주', '경북', '대구', '경남', '울산', '부산', '제주']
|
||||
const PRODUCT_TYPE: Record<string, string> = {
|
||||
e01: '인터넷', e02: '전화', e03: 'TV', e04: '렌탈',
|
||||
}
|
||||
|
||||
export type PreparedRow = {
|
||||
orderId: number
|
||||
receptionId?: number
|
||||
status: string
|
||||
plus: boolean
|
||||
orderDate: string
|
||||
opened: string
|
||||
centerName: string
|
||||
ispName: string
|
||||
productType: string
|
||||
productName: string
|
||||
eventNames: string
|
||||
agreementName: string
|
||||
sido: string
|
||||
customerName: string
|
||||
ssidUseCount: number | null
|
||||
contractNumber: string
|
||||
minusCategory: string
|
||||
giftType: string
|
||||
giftPlace: string
|
||||
giftStatus: string
|
||||
progressMemo: string
|
||||
cost: number
|
||||
event: number
|
||||
agreement: number
|
||||
local: number
|
||||
gift: number
|
||||
voc: number
|
||||
etc: number
|
||||
total: number
|
||||
prevCost: number
|
||||
prevEvent: number
|
||||
prevAgreement: number
|
||||
prevLocal: number
|
||||
prevGift: number
|
||||
prevVoc: number
|
||||
prevEtc: number
|
||||
prevTotal: number
|
||||
negateOnTransfer: boolean
|
||||
}
|
||||
|
||||
type DetailResponse = {
|
||||
branch?: { branchId: string; officeName: string; name: string; phone: string; tel: string }
|
||||
center?: { centerId: string; name: string; manager: string; tel: string; phone: string }
|
||||
plus: { content: PreparedRow[]; page: number; totalPages: number; totalElements: number }
|
||||
minus: { content: PreparedRow[]; page: number; totalPages: number; totalElements: number }
|
||||
}
|
||||
|
||||
type FeeField = 'cost' | 'event' | 'agreement' | 'local' | 'gift' | 'voc' | 'etc'
|
||||
|
||||
type RowEdit = {
|
||||
memo: string
|
||||
cost: number
|
||||
event: number
|
||||
agreement: number
|
||||
local: number
|
||||
gift: number
|
||||
voc: number
|
||||
etc: number
|
||||
}
|
||||
|
||||
type FeeSuggestion = {
|
||||
orderId?: number
|
||||
orderCenterId?: number
|
||||
receptionId?: number
|
||||
cost: number
|
||||
event: number
|
||||
agreement: number
|
||||
local: number
|
||||
gift: number
|
||||
voc: number
|
||||
etc: number
|
||||
total: number
|
||||
}
|
||||
|
||||
function defaultFilters() {
|
||||
return {
|
||||
orderDateStart: '2010-01-01',
|
||||
orderDateEnd: today(),
|
||||
dateType: 'order_date' as 'order_date' | 'opended',
|
||||
perPage: 50,
|
||||
sorting: 'order_date',
|
||||
orderBy: 'desc',
|
||||
productType: [] as string[],
|
||||
carrier: [] as string[],
|
||||
customerSido: [] as string[],
|
||||
customerName: '',
|
||||
contractNumber: '',
|
||||
}
|
||||
}
|
||||
|
||||
type FilterState = ReturnType<typeof defaultFilters>
|
||||
|
||||
const FEE_LABELS: Record<FeeField, string> = {
|
||||
cost: '단가',
|
||||
event: '이벤트적용',
|
||||
agreement: '약정적용',
|
||||
local: '지역적용',
|
||||
gift: '사은품차감',
|
||||
voc: 'VOC차감',
|
||||
etc: '추가정산',
|
||||
}
|
||||
|
||||
function quickRanges() {
|
||||
const now = dayjs()
|
||||
const d = (x: dayjs.Dayjs) => x.format('YYYY-MM-DD')
|
||||
const dow = now.day()
|
||||
return [
|
||||
{ label: '금일', start: d(now), end: d(now) },
|
||||
{ label: '금주', start: d(now.subtract(dow, 'day')), end: d(now.add(6 - dow, 'day')) },
|
||||
{ label: '전주', start: d(now.subtract(dow + 7, 'day')), end: d(now.subtract(dow + 1, 'day')) },
|
||||
{ label: '금월', start: d(now.startOf('month')), end: d(now.endOf('month')) },
|
||||
{ label: '전월', start: d(now.subtract(1, 'month').startOf('month')), end: d(now.subtract(1, 'month').endOf('month')) },
|
||||
{ label: '금년', start: d(now.startOf('year')), end: d(now.endOf('year')) },
|
||||
{ label: '전년', start: d(now.subtract(1, 'year').startOf('year')), end: d(now.subtract(1, 'year').endOf('year')) },
|
||||
]
|
||||
}
|
||||
|
||||
/** 가정산 상세 — billing_react PreparedDetailEditor 이식 */
|
||||
export default function PreparedDetailEditor({
|
||||
kind, row, onDone,
|
||||
}: {
|
||||
kind: Kind
|
||||
row: Summary
|
||||
onDone: () => void
|
||||
}) {
|
||||
const base = kind === 'branch'
|
||||
? `${apiPrefix()}/homes/calculate`
|
||||
: `${apiPrefix()}/homes/center-calculate`
|
||||
const qc = useQueryClient()
|
||||
const [filters, setFilters] = useState<FilterState>(defaultFilters)
|
||||
const [applied, setApplied] = useState<FilterState>(defaultFilters)
|
||||
const [page, setPage] = useState(0)
|
||||
const [page2, setPage2] = useState(0)
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set())
|
||||
const [edits, setEdits] = useState<Record<number, RowEdit>>({})
|
||||
|
||||
const applyFilter = (patch: Partial<FilterState>) => {
|
||||
setFilters((prev) => {
|
||||
const next = { ...prev, ...patch }
|
||||
setApplied(next)
|
||||
return next
|
||||
})
|
||||
setPage(0)
|
||||
setPage2(0)
|
||||
}
|
||||
|
||||
const params = useMemo(() => ({
|
||||
entity: row.entityKey,
|
||||
orderDateStart: applied.orderDateStart,
|
||||
orderDateEnd: applied.orderDateEnd,
|
||||
dateType: applied.dateType,
|
||||
page,
|
||||
page2,
|
||||
perPage: applied.perPage,
|
||||
sorting: applied.sorting,
|
||||
orderBy: applied.orderBy,
|
||||
customerName: applied.customerName.trim() || undefined,
|
||||
contractNumber: applied.contractNumber.trim() || undefined,
|
||||
customerSido: applied.customerSido.length ? applied.customerSido : undefined,
|
||||
productType: applied.productType.length ? applied.productType : undefined,
|
||||
carrier: applied.carrier.length ? applied.carrier : undefined,
|
||||
}), [applied, page, page2, row.entityKey])
|
||||
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['calc', kind, 'prepared-detail', row.entityKey, params],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<DetailResponse>>(`${base}/prepared/detail`, { params })
|
||||
return unwrap(data)
|
||||
},
|
||||
})
|
||||
|
||||
const { data: suggestions } = useQuery({
|
||||
queryKey: ['calc', kind, 'suggest', row.entityKey],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<FeeSuggestion[]>>(`${base}/suggest`, {
|
||||
params: { entity: row.entityKey },
|
||||
})
|
||||
return unwrap(data) || []
|
||||
},
|
||||
})
|
||||
|
||||
const suggestById = useMemo(() => {
|
||||
const m = new Map<number, FeeSuggestion>()
|
||||
for (const s of suggestions ?? []) {
|
||||
const id = kind === 'branch' ? (s.orderId ?? s.receptionId) : (s.orderCenterId ?? s.receptionId)
|
||||
if (id != null) m.set(id, s)
|
||||
}
|
||||
return m
|
||||
}, [suggestions, kind])
|
||||
|
||||
const carriers = useMemo(() => {
|
||||
const set = new Set<string>()
|
||||
for (const r of [...(data?.plus.content ?? []), ...(data?.minus.content ?? [])]) {
|
||||
if (r.ispName) set.add(r.ispName)
|
||||
}
|
||||
return [...set].sort()
|
||||
}, [data])
|
||||
|
||||
const initEdit = (r: PreparedRow): RowEdit => {
|
||||
const existing = edits[r.orderId]
|
||||
if (existing) return existing
|
||||
const s = suggestById.get(r.orderId)
|
||||
return {
|
||||
memo: '',
|
||||
cost: s?.cost ?? r.cost,
|
||||
event: s?.event ?? r.event,
|
||||
agreement: s?.agreement ?? r.agreement,
|
||||
local: s?.local ?? r.local,
|
||||
gift: s?.gift ?? r.gift,
|
||||
voc: s?.voc ?? r.voc,
|
||||
etc: s?.etc ?? r.etc,
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!suggestions?.length || !data) return
|
||||
setEdits((prev) => {
|
||||
const next = { ...prev }
|
||||
let changed = false
|
||||
for (const r of [...data.plus.content, ...data.minus.content]) {
|
||||
if (prev[r.orderId]) continue
|
||||
const s = suggestById.get(r.orderId)
|
||||
if (!s) continue
|
||||
next[r.orderId] = {
|
||||
memo: '',
|
||||
cost: s.cost, event: s.event, agreement: s.agreement, local: s.local,
|
||||
gift: s.gift, voc: s.voc, etc: s.etc,
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
return changed ? next : prev
|
||||
})
|
||||
}, [suggestions, data, suggestById])
|
||||
|
||||
const totalOf = (e: RowEdit) => e.cost + e.event + e.agreement + e.local + e.gift + e.voc + e.etc
|
||||
|
||||
const patch = (orderId: number, field: keyof RowEdit, value: string | number) => {
|
||||
setEdits((prev) => {
|
||||
const all = [...(data?.plus.content ?? []), ...(data?.minus.content ?? [])]
|
||||
const base = prev[orderId] ?? initEdit(all.find((x) => x.orderId === orderId)!)
|
||||
return { ...prev, [orderId]: { ...base, [field]: value } }
|
||||
})
|
||||
}
|
||||
|
||||
const toggle = (id: number) => setSelected((p) => {
|
||||
const n = new Set(p)
|
||||
if (n.has(id)) n.delete(id)
|
||||
else n.add(id)
|
||||
return n
|
||||
})
|
||||
|
||||
const submit = useMutation({
|
||||
mutationFn: async () => {
|
||||
const ids = [...selected]
|
||||
if (!ids.length) throw new Error('리스트를 선택하세요.')
|
||||
const all = [...(data?.plus.content ?? []), ...(data?.minus.content ?? [])]
|
||||
const lines = ids.map((id) => {
|
||||
const r = all.find((x) => x.orderId === id)!
|
||||
const e = edits[id] ?? initEdit(r)
|
||||
return {
|
||||
receptionId: id,
|
||||
cost: e.cost, event: e.event, agreement: e.agreement, local: e.local,
|
||||
gift: e.gift, voc: e.voc, etc: e.etc, memo: e.memo,
|
||||
}
|
||||
})
|
||||
const { data: res } = await client.post(`${base}/toss-waiting`, { entity: row.entityKey, lines })
|
||||
return unwrap(res)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('정산대기로 등록되었습니다.')
|
||||
setSelected(new Set())
|
||||
qc.invalidateQueries({ queryKey: ['calc', kind] })
|
||||
onDone()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const cancelOne = useMutation({
|
||||
mutationFn: async (orderId: number) => {
|
||||
const { data: res } = await client.post(`${base}/prepared/cancel`, {
|
||||
receptionIds: [orderId],
|
||||
orderIds: [orderId],
|
||||
orderCenterIds: [orderId],
|
||||
})
|
||||
return unwrap(res)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('가정산을 취소했습니다.')
|
||||
refetch()
|
||||
qc.invalidateQueries({ queryKey: ['calc'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const cancelMany = useMutation({
|
||||
mutationFn: async () => {
|
||||
const ids = [...selected]
|
||||
if (!ids.length) throw new Error('취소할 가정산 리스트를 선택하세요.')
|
||||
const { data: res } = await client.post(`${base}/prepared/cancel`, {
|
||||
receptionIds: ids,
|
||||
orderIds: ids,
|
||||
orderCenterIds: ids,
|
||||
})
|
||||
return unwrap(res)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('선택한 가정산을 취소했습니다.')
|
||||
setSelected(new Set())
|
||||
refetch()
|
||||
qc.invalidateQueries({ queryKey: ['calc'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const search = () => { setApplied({ ...filters }); setPage(0); setPage2(0) }
|
||||
const resetSearch = () => {
|
||||
const init = defaultFilters()
|
||||
setFilters(init)
|
||||
setApplied(init)
|
||||
setPage(0)
|
||||
setPage2(0)
|
||||
}
|
||||
|
||||
const toggleSort = (col: string) => {
|
||||
setFilters((f) => {
|
||||
const orderBy = f.sorting === col && f.orderBy === 'desc' ? 'asc' : 'desc'
|
||||
const next = { ...f, sorting: col, orderBy }
|
||||
setApplied(next)
|
||||
return next
|
||||
})
|
||||
setPage(0)
|
||||
setPage2(0)
|
||||
}
|
||||
|
||||
const sortMark = (col: string) => {
|
||||
if (applied.sorting !== col) return '↕'
|
||||
return applied.orderBy === 'asc' ? '▲' : '▼'
|
||||
}
|
||||
|
||||
const feeFields: FeeField[] = ['cost', 'event', 'agreement', 'local', 'gift', 'voc', 'etc']
|
||||
const ptLabel = (code: string) => PRODUCT_TYPE[code] || code || ''
|
||||
|
||||
const plusColumns = [
|
||||
{
|
||||
title: (
|
||||
<Checkbox
|
||||
onChange={(e) => {
|
||||
const ids = (data?.plus.content ?? []).map((r) => r.orderId)
|
||||
setSelected(e.target.checked
|
||||
? new Set([...selected, ...ids])
|
||||
: new Set([...selected].filter((id) => !ids.includes(id))))
|
||||
}}
|
||||
/>
|
||||
),
|
||||
width: 44,
|
||||
fixed: 'left' as const,
|
||||
render: (_: unknown, r: PreparedRow) => (
|
||||
<Checkbox checked={selected.has(r.orderId)} onChange={() => toggle(r.orderId)} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '번호',
|
||||
width: 70,
|
||||
render: (_: unknown, r: PreparedRow, idx: number) => (
|
||||
<div className="center">
|
||||
<div>{r.orderId.toLocaleString()}</div>
|
||||
<div className="muted">({idx + 1 + page * applied.perPage})</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: <button type="button" className="linkish" onClick={() => toggleSort('order_date')}>{sortMark('order_date')}접수일</button>,
|
||||
width: 100,
|
||||
render: (_: unknown, r: PreparedRow) => r.orderDate.replace(' ', '\n'),
|
||||
},
|
||||
{
|
||||
title: <button type="button" className="linkish" onClick={() => toggleSort('opended')}>{sortMark('opended')}개통일</button>,
|
||||
width: 100,
|
||||
dataIndex: 'opened',
|
||||
},
|
||||
{
|
||||
title: <button type="button" className="linkish" onClick={() => toggleSort('center')}>{sortMark('center')}입력처</button>,
|
||||
width: 90,
|
||||
dataIndex: 'centerName',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: <button type="button" className="linkish" onClick={() => toggleSort('isp')}>{sortMark('isp')}통신사</button>,
|
||||
width: 80,
|
||||
dataIndex: 'ispName',
|
||||
},
|
||||
{
|
||||
title: <button type="button" className="linkish" onClick={() => toggleSort('product_type')}>{sortMark('product_type')}상품종류</button>,
|
||||
width: 80,
|
||||
render: (_: unknown, r: PreparedRow) => ptLabel(r.productType),
|
||||
},
|
||||
{
|
||||
title: <button type="button" className="linkish" onClick={() => toggleSort('product')}>{sortMark('product')}상품명</button>,
|
||||
width: 120,
|
||||
dataIndex: 'productName',
|
||||
ellipsis: true,
|
||||
},
|
||||
{ title: '이벤트', width: 80, dataIndex: 'eventNames', ellipsis: true },
|
||||
{
|
||||
title: <button type="button" className="linkish" onClick={() => toggleSort('agreement')}>{sortMark('agreement')}약정</button>,
|
||||
width: 80,
|
||||
dataIndex: 'agreementName',
|
||||
},
|
||||
{
|
||||
title: <button type="button" className="linkish" onClick={() => toggleSort('sido')}>{sortMark('sido')}지역</button>,
|
||||
width: 60,
|
||||
dataIndex: 'sido',
|
||||
},
|
||||
{
|
||||
title: <button type="button" className="linkish" onClick={() => toggleSort('name')}>{sortMark('name')}이름</button>,
|
||||
width: 90,
|
||||
render: (_: unknown, r: PreparedRow) => (
|
||||
<div>
|
||||
<div>{r.customerName}</div>
|
||||
{r.ssidUseCount != null && r.ssidUseCount > 1 && <div style={{ color: '#c00' }}>({r.ssidUseCount})</div>}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: <button type="button" className="linkish" onClick={() => toggleSort('contract_number')}>{sortMark('contract_number')}계약번호</button>,
|
||||
width: 110,
|
||||
dataIndex: 'contractNumber',
|
||||
},
|
||||
{
|
||||
title: '추가메모',
|
||||
width: 90,
|
||||
render: (_: unknown, r: PreparedRow) => {
|
||||
const e = edits[r.orderId] ?? initEdit(r)
|
||||
return <Input size="small" value={e.memo} onChange={(ev) => patch(r.orderId, 'memo', ev.target.value)} />
|
||||
},
|
||||
},
|
||||
...feeFields.map((f) => ({
|
||||
title: FEE_LABELS[f],
|
||||
width: 88,
|
||||
align: 'right' as const,
|
||||
render: (_: unknown, r: PreparedRow) => {
|
||||
const e = edits[r.orderId] ?? initEdit(r)
|
||||
return (
|
||||
<InputNumber
|
||||
{...moneyInputProps}
|
||||
className="calc-money-input"
|
||||
value={e[f]}
|
||||
onChange={(v) => patch(r.orderId, f, Number(v || 0))}
|
||||
/>
|
||||
)
|
||||
},
|
||||
})),
|
||||
{
|
||||
title: '최종정산금액',
|
||||
width: 110,
|
||||
align: 'right' as const,
|
||||
render: (_: unknown, r: PreparedRow) => {
|
||||
const e = edits[r.orderId] ?? initEdit(r)
|
||||
return <b>{won(totalOf(e))}</b>
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '취소',
|
||||
width: 70,
|
||||
fixed: 'right' as const,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, r: PreparedRow) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
danger
|
||||
className="branch-row-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
if (window.confirm('가정산을 취소합니다.')) cancelOne.mutate(r.orderId)
|
||||
}}
|
||||
>
|
||||
취소
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '수수료현황',
|
||||
width: 100,
|
||||
fixed: 'right' as const,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, r: PreparedRow) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="branch-row-btn fee-status-row-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void openFeeOrderPopup(r.orderId, kind)
|
||||
}}
|
||||
>
|
||||
수수료현황
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
const minusColumns = [
|
||||
{
|
||||
title: (
|
||||
<Checkbox
|
||||
onChange={(e) => {
|
||||
const ids = (data?.minus.content ?? []).map((r) => r.orderId)
|
||||
setSelected(e.target.checked
|
||||
? new Set([...selected, ...ids])
|
||||
: new Set([...selected].filter((id) => !ids.includes(id))))
|
||||
}}
|
||||
/>
|
||||
),
|
||||
width: 44,
|
||||
render: (_: unknown, r: PreparedRow) => (
|
||||
<Checkbox checked={selected.has(r.orderId)} onChange={() => toggle(r.orderId)} />
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '번호',
|
||||
width: 70,
|
||||
render: (_: unknown, r: PreparedRow, idx: number) => (
|
||||
<div>
|
||||
<div>{r.orderId.toLocaleString()}</div>
|
||||
<div className="muted">({idx + 1 + page2 * applied.perPage})</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{ title: '접수일', width: 100, render: (_: unknown, r: PreparedRow) => r.orderDate.replace(' ', '\n') },
|
||||
{ title: '개통일', width: 100, dataIndex: 'opened' },
|
||||
{ title: '입력처', width: 90, dataIndex: 'centerName' },
|
||||
{
|
||||
title: '통신사/상품',
|
||||
width: 120,
|
||||
render: (_: unknown, r: PreparedRow) => (
|
||||
<div><div>{r.ispName}</div><div>{r.productName}</div></div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '고객/약정',
|
||||
width: 110,
|
||||
render: (_: unknown, r: PreparedRow) => (
|
||||
<div>
|
||||
<div>{r.customerName}</div>
|
||||
<div>{r.agreementName}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '환수구분/계약번호',
|
||||
width: 130,
|
||||
render: (_: unknown, r: PreparedRow) => (
|
||||
<div>
|
||||
<div>{r.minusCategory || progressLabel(r.status)}</div>
|
||||
<div>{r.contractNumber}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '사은품',
|
||||
width: 100,
|
||||
render: (_: unknown, r: PreparedRow) => (
|
||||
<div>
|
||||
<div>{r.giftType}</div>
|
||||
<div>{GIFT_PLACE[r.giftPlace] || r.giftPlace}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '내역',
|
||||
width: 110,
|
||||
render: (_: unknown, r: PreparedRow) => (
|
||||
<div>
|
||||
<div title={r.progressMemo}>{r.progressMemo}</div>
|
||||
<div>{GIFT_STATUS[r.giftStatus] || r.giftStatus}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '추가메모',
|
||||
width: 90,
|
||||
render: (_: unknown, r: PreparedRow) => {
|
||||
const e = edits[r.orderId] ?? initEdit(r)
|
||||
return <Input size="small" value={e.memo} onChange={(ev) => patch(r.orderId, 'memo', ev.target.value)} />
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '구분',
|
||||
width: 70,
|
||||
render: () => <div><div>이전정산</div><div>환수금액</div></div>,
|
||||
},
|
||||
...feeFields.map((f) => ({
|
||||
title: FEE_LABELS[f].replace('적용', '').replace('차감', ''),
|
||||
width: 88,
|
||||
align: 'right' as const,
|
||||
render: (_: unknown, r: PreparedRow) => {
|
||||
const e = edits[r.orderId] ?? initEdit(r)
|
||||
const prevMap: Record<FeeField, number> = {
|
||||
cost: r.prevCost, event: r.prevEvent, agreement: r.prevAgreement, local: r.prevLocal,
|
||||
gift: r.prevGift, voc: r.prevVoc, etc: r.prevEtc,
|
||||
}
|
||||
return (
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<InputNumber
|
||||
{...moneyInputProps}
|
||||
value={prevMap[f]}
|
||||
disabled
|
||||
style={{ ...moneyInputProps.style, marginBottom: 2 }}
|
||||
/>
|
||||
<InputNumber
|
||||
{...moneyInputProps}
|
||||
value={e[f]}
|
||||
onChange={(v) => patch(r.orderId, f, Number(v || 0))}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
})),
|
||||
{
|
||||
title: '최종환수',
|
||||
width: 110,
|
||||
align: 'right' as const,
|
||||
render: (_: unknown, r: PreparedRow) => {
|
||||
const e = edits[r.orderId] ?? initEdit(r)
|
||||
return (
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
<div>
|
||||
<InputNumber
|
||||
{...moneyInputProps}
|
||||
value={r.prevTotal}
|
||||
disabled
|
||||
style={{ ...moneyInputProps.style, width: 90 }}
|
||||
/>
|
||||
</div>
|
||||
<div><b>{won(totalOf(e))}</b></div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '수수료현황',
|
||||
width: 100,
|
||||
fixed: 'right' as const,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, r: PreparedRow) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="branch-row-btn fee-status-row-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
void openFeeOrderPopup(r.orderId, kind)
|
||||
}}
|
||||
>
|
||||
수수료현황
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="prepared-detail">
|
||||
<p className="prepared-detail-subtitle"><b>{row.title}</b> — 가정산 상세페이지</p>
|
||||
|
||||
{data?.center && (
|
||||
<table className="prg-table" style={{ marginTop: 8 }}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>입력처명</th><td>{data.center.name}</td>
|
||||
<th>담당자</th><td>{data.center.manager}</td>
|
||||
<th>연락처</th><td>{data.center.phone}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
{data?.branch && (
|
||||
<table className="prg-table" style={{ marginTop: 8 }}>
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>영업점명</th><td>{data.branch.officeName}</td>
|
||||
<th>이름</th><td>{data.branch.name}</td>
|
||||
<th>휴대전화</th><td>{data.branch.phone}</td>
|
||||
<th>일반전화</th><td>{data.branch.tel}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
|
||||
<Space wrap style={{ marginTop: 12 }} size={8}>
|
||||
<Radio.Group
|
||||
value={filters.dateType}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, dateType: e.target.value }))}
|
||||
optionType="button"
|
||||
options={[
|
||||
{ value: 'order_date', label: '접수일' },
|
||||
{ value: 'opended', label: '개통일' },
|
||||
]}
|
||||
/>
|
||||
<DatePicker value={dayjs(filters.orderDateStart)} onChange={(d) => setFilters((f) => ({ ...f, orderDateStart: d?.format('YYYY-MM-DD') || '2010-01-01' }))} />
|
||||
<span>~</span>
|
||||
<DatePicker value={dayjs(filters.orderDateEnd)} onChange={(d) => setFilters((f) => ({ ...f, orderDateEnd: d?.format('YYYY-MM-DD') || today() }))} />
|
||||
{quickRanges().map((q) => (
|
||||
<Button key={q.label} size="small" onClick={() => {
|
||||
const next = { ...filters, orderDateStart: q.start, orderDateEnd: q.end }
|
||||
setFilters(next)
|
||||
setApplied(next)
|
||||
setPage(0)
|
||||
setPage2(0)
|
||||
}}>{q.label}</Button>
|
||||
))}
|
||||
<span>한 페이지당 <InputNumber min={10} value={filters.perPage} onChange={(v) => setFilters((f) => ({ ...f, perPage: Math.max(10, Number(v || 50)) }))} style={{ width: 64 }} /></span>
|
||||
<Button type="primary" onClick={search}>검색</Button>
|
||||
<Button onClick={resetSearch}>검색취소</Button>
|
||||
</Space>
|
||||
|
||||
<Space wrap style={{ marginTop: 8 }} size={8}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
placeholder="통신사"
|
||||
style={{ minWidth: 140 }}
|
||||
value={filters.carrier}
|
||||
onChange={(v) => applyFilter({ carrier: v })}
|
||||
options={carriers.map((c) => ({ value: c, label: c }))}
|
||||
/>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
placeholder="상품종류"
|
||||
style={{ minWidth: 140 }}
|
||||
value={filters.productType}
|
||||
onChange={(v) => applyFilter({ productType: v })}
|
||||
options={Object.entries(PRODUCT_TYPE).map(([k, v]) => ({ value: k, label: v }))}
|
||||
/>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
placeholder="지역"
|
||||
style={{ minWidth: 140 }}
|
||||
value={filters.customerSido}
|
||||
onChange={(v) => applyFilter({ customerSido: v })}
|
||||
options={SIDO_LIST.map((s) => ({ value: s, label: s }))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="고객명"
|
||||
value={filters.customerName}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, customerName: e.target.value }))}
|
||||
onPressEnter={search}
|
||||
style={{ width: 120 }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="개통번호"
|
||||
value={filters.contractNumber}
|
||||
onChange={(e) => setFilters((f) => ({ ...f, contractNumber: e.target.value }))}
|
||||
onPressEnter={search}
|
||||
style={{ width: 140 }}
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<h4 style={{ marginTop: 16, fontSize: 14 }}>
|
||||
정산 리스트
|
||||
{data ? <span className="muted"> ({data.plus.totalElements})</span> : null}
|
||||
</h4>
|
||||
<Table
|
||||
className="calc-prepared-table"
|
||||
rowKey="orderId"
|
||||
size="small"
|
||||
loading={isLoading}
|
||||
scroll={{ x: 2200 }}
|
||||
pagination={data && data.plus.totalPages > 1 ? {
|
||||
current: page + 1,
|
||||
pageSize: applied.perPage,
|
||||
total: data.plus.totalElements,
|
||||
onChange: (p) => setPage(p - 1),
|
||||
showSizeChanger: false,
|
||||
} : false}
|
||||
dataSource={data?.plus.content || []}
|
||||
columns={plusColumns}
|
||||
rowClassName={(r) => (selected.has(r.orderId) ? 'calc-row-selected' : '')}
|
||||
onRow={(r) => ({
|
||||
style: { cursor: 'pointer' },
|
||||
onClick: (e) => {
|
||||
if (!isFeeRowClickTarget(e.target)) return
|
||||
void openFeeOrderPopup(r.orderId, kind)
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
||||
<h4 style={{ marginTop: 20, fontSize: 14 }}>
|
||||
해지 리스트
|
||||
{data ? <span className="muted"> ({data.minus.totalElements})</span> : null}
|
||||
</h4>
|
||||
<Table
|
||||
className="calc-prepared-table"
|
||||
rowKey="orderId"
|
||||
size="small"
|
||||
loading={isLoading}
|
||||
scroll={{ x: 2000 }}
|
||||
pagination={data && data.minus.totalPages > 1 ? {
|
||||
current: page2 + 1,
|
||||
pageSize: applied.perPage,
|
||||
total: data.minus.totalElements,
|
||||
onChange: (p) => setPage2(p - 1),
|
||||
showSizeChanger: false,
|
||||
} : false}
|
||||
dataSource={data?.minus.content || []}
|
||||
columns={minusColumns}
|
||||
rowClassName={(r) => (selected.has(r.orderId) ? 'calc-row-selected' : '')}
|
||||
onRow={(r) => ({
|
||||
style: { cursor: 'pointer' },
|
||||
onClick: (e) => {
|
||||
if (!isFeeRowClickTarget(e.target)) return
|
||||
void openFeeOrderPopup(r.orderId, kind)
|
||||
},
|
||||
})}
|
||||
/>
|
||||
|
||||
<Space style={{ marginTop: 16 }} size={8}>
|
||||
<Button type="primary" loading={submit.isPending} onClick={() => submit.mutate()}>
|
||||
정산대기로 보내기
|
||||
</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
danger
|
||||
className="branch-row-btn"
|
||||
loading={cancelMany.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm('선택한 가정산을 취소합니다.')) cancelMany.mutate()
|
||||
}}
|
||||
>
|
||||
가정산 일괄 취소
|
||||
</Button>
|
||||
<span className="muted" style={{ fontSize: 12 }}>선택 {selected.size}건 · 행 클릭: 수수료 현황</span>
|
||||
</Space>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -325,7 +325,7 @@ export default function ProductHomePage() {
|
||||
)}
|
||||
<div className="product-modal-footer-right">
|
||||
<Button type="primary" htmlType="submit" loading={save.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => setOpen(false)}>취소</Button>
|
||||
</div>
|
||||
|
||||
@@ -325,7 +325,7 @@ export default function ProductInternetPage() {
|
||||
)}
|
||||
<div className="product-modal-footer-right">
|
||||
<Button type="primary" htmlType="submit" loading={save.isPending}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => setOpen(false)}>취소</Button>
|
||||
</div>
|
||||
|
||||
@@ -18,29 +18,18 @@ import ReceptionProgressModal from '../../components/ReceptionProgressModal'
|
||||
import { DownOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
import { options } from './homesShared'
|
||||
|
||||
/** 진행현황 배지 색상 클래스 */
|
||||
function progressBadgeClass(status?: string) {
|
||||
const s = (status || '').trim()
|
||||
if (s.startsWith('1.') || s.includes('접수완료')) return 'badge badge-ps-1'
|
||||
if (s.startsWith('2.') || s.includes('고객통화')) return 'badge badge-ps-2'
|
||||
if (s.startsWith('3.') || s.includes('서류검토')) return 'badge badge-ps-3'
|
||||
if (s.startsWith('4.') || s.includes('개통완료')) return 'badge badge-ps-4'
|
||||
if (s.startsWith('5.') || s.includes('사은품지급')) return 'badge badge-ps-5'
|
||||
if (s.startsWith('6.') || s.includes('정산완료')) return 'badge badge-ps-6'
|
||||
if (s.startsWith('9.') || s.includes('취소')) return 'badge badge-ps-9'
|
||||
return 'badge'
|
||||
}
|
||||
|
||||
/** 사은품현황 배지 색상 클래스 */
|
||||
function giftBadgeClass(status?: string) {
|
||||
const s = (status || '').trim()
|
||||
if (s === '미지급') return 'badge badge-gs-none'
|
||||
if (s === '지급대기') return 'badge badge-gs-wait'
|
||||
if (s === '지급완료') return 'badge badge-gs-done'
|
||||
if (s === '반송') return 'badge badge-gs-return'
|
||||
return 'badge'
|
||||
}
|
||||
import {
|
||||
progressBadgeClass,
|
||||
giftBadgeClass,
|
||||
progressLabel,
|
||||
giftLabel,
|
||||
centerLabel,
|
||||
giftPlaceLabel as placeLabel,
|
||||
statusOptions,
|
||||
PROGRESS_STATUS,
|
||||
GIFT_STATUS,
|
||||
CENTER_STATUS,
|
||||
} from './receptionStatuses'
|
||||
|
||||
type ReceptionRow = {
|
||||
id: number
|
||||
@@ -64,9 +53,12 @@ type ReceptionRow = {
|
||||
region?: string
|
||||
sido?: string
|
||||
progressStatus?: string
|
||||
progressStatusLabel?: string
|
||||
status?: string
|
||||
settleStatus?: string
|
||||
settleStatusLabel?: string
|
||||
centerStatus?: string
|
||||
centerStatusLabel?: string
|
||||
centerName?: string
|
||||
openDate?: string
|
||||
opened?: string
|
||||
@@ -75,6 +67,7 @@ type ReceptionRow = {
|
||||
giftDate?: string
|
||||
giftSended?: string
|
||||
giftStatus?: string
|
||||
giftStatusLabel?: string
|
||||
giftPlace?: string
|
||||
giftAmount?: number
|
||||
agreementName?: string
|
||||
@@ -91,6 +84,9 @@ type ReceptionResult = {
|
||||
progressStatuses?: string[]
|
||||
settleStatuses?: string[]
|
||||
giftStatuses?: string[]
|
||||
progressStatusLabels?: Record<string, string>
|
||||
giftStatusLabels?: Record<string, string>
|
||||
settleStatusLabels?: Record<string, string>
|
||||
carriers?: string[]
|
||||
branches?: string[]
|
||||
counselors?: string[]
|
||||
@@ -105,13 +101,6 @@ const SIDO_LIST = [
|
||||
'경북', '대구', '경남', '울산', '부산', '제주',
|
||||
]
|
||||
|
||||
const GIFT_PLACE_LABEL: Record<string, string> = {
|
||||
b01: '본사요청',
|
||||
b02: '자체발송',
|
||||
본사: '본사발송',
|
||||
영업점: '영업점발송',
|
||||
}
|
||||
|
||||
const initialFilters = () => ({
|
||||
page: 0,
|
||||
size: 50,
|
||||
@@ -144,10 +133,7 @@ const initialFilters = () => ({
|
||||
|
||||
const num = (n?: number | null) => (n == null ? '' : Number(n).toLocaleString())
|
||||
|
||||
const giftPlaceLabel = (v?: string) => {
|
||||
if (!v) return ''
|
||||
return GIFT_PLACE_LABEL[v] || v
|
||||
}
|
||||
const giftPlaceLabel = (v?: string) => placeLabel(v)
|
||||
|
||||
const splitDt = (v?: string) => {
|
||||
if (!v) return ['', '']
|
||||
@@ -221,11 +207,17 @@ export default function ReceptionListPage() {
|
||||
setSelected([])
|
||||
}
|
||||
|
||||
const progressStatuses = query.data?.progressStatuses || []
|
||||
const settleStatuses = query.data?.settleStatuses || []
|
||||
const giftStatuses = query.data?.giftStatuses || []
|
||||
const progressStatuses = query.data?.progressStatuses || Object.keys(PROGRESS_STATUS)
|
||||
const giftStatuses = query.data?.giftStatuses || Object.keys(GIFT_STATUS)
|
||||
const progressLabels = query.data?.progressStatusLabels || PROGRESS_STATUS
|
||||
const giftLabels = query.data?.giftStatusLabels || GIFT_STATUS
|
||||
const settleLabels = query.data?.settleStatusLabels || CENTER_STATUS
|
||||
const list = query.data?.list || []
|
||||
|
||||
const progressSelectOpts = statusOptions(progressLabels as Record<string, string>)
|
||||
const giftSelectOpts = statusOptions(giftLabels as Record<string, string>)
|
||||
const settleSelectOpts = statusOptions(settleLabels as Record<string, string>)
|
||||
|
||||
const setD = (patch: Partial<ReturnType<typeof initialFilters>>) => setDraft((p) => ({ ...p, ...patch }))
|
||||
const applyF = (patch: Partial<ReturnType<typeof initialFilters>>) => {
|
||||
const next = { ...draft, ...patch, page: 0 }
|
||||
@@ -416,8 +408,6 @@ export default function ReceptionListPage() {
|
||||
<div className="toolbar order-top-buttons">
|
||||
<Button type="primary" onClick={search}>검색</Button>
|
||||
<Button onClick={reset}>검색 초기화</Button>
|
||||
<Button onClick={downloadExcel}>Excel 다운로드</Button>
|
||||
<Button onClick={downloadExcel}>사은품 Excel 다운로드</Button>
|
||||
</div>
|
||||
|
||||
<div className="order-filter">
|
||||
@@ -456,6 +446,13 @@ export default function ReceptionListPage() {
|
||||
onChange={(e) => setD({ size: Number(e.target.value) || 50 })}
|
||||
/>
|
||||
</label>
|
||||
<div className="order-attach-buttons">
|
||||
<Button onClick={downloadExcel}>Excel 다운로드</Button>
|
||||
<Button onClick={downloadExcel}>사은품 Excel 다운로드</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="order-result-row">
|
||||
<span className="order-result muted">검색 결과: 총 {(query.data?.total ?? 0).toLocaleString()} 건</span>
|
||||
</div>
|
||||
|
||||
@@ -532,7 +529,7 @@ export default function ReceptionListPage() {
|
||||
placeholder="입력처"
|
||||
value={draft.settleStatus}
|
||||
onChange={(v) => applyF({ settleStatus: v })}
|
||||
options={options(settleStatuses)}
|
||||
options={settleSelectOpts}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</th>
|
||||
@@ -604,7 +601,7 @@ export default function ReceptionListPage() {
|
||||
placeholder="진행현황"
|
||||
value={draft.progressStatus}
|
||||
onChange={(v) => applyF({ progressStatus: v })}
|
||||
options={options(progressStatuses)}
|
||||
options={progressSelectOpts}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</th>
|
||||
@@ -639,7 +636,7 @@ export default function ReceptionListPage() {
|
||||
placeholder="사은품진행"
|
||||
value={draft.giftStatus}
|
||||
onChange={(v) => applyF({ giftStatus: v })}
|
||||
options={options(giftStatuses)}
|
||||
options={giftSelectOpts}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</th>
|
||||
@@ -701,7 +698,7 @@ export default function ReceptionListPage() {
|
||||
</td>
|
||||
<td className="col-status floating-cell">
|
||||
<p className="c-green">{o.employeeName || o.counselor || ''}</p>
|
||||
<p><span className={progressBadgeClass(o.status || o.progressStatus)}>{o.status || o.progressStatus || ''}</span></p>
|
||||
<p><span className={progressBadgeClass(o.progressStatus || o.status)}>{o.progressStatusLabel || progressLabel(o.progressStatus || o.status) || ''}</span></p>
|
||||
<div className="floating-layer">
|
||||
<p>개통일: {o.opened || o.openDate || '-'}</p>
|
||||
<p>해지일: {o.canceled || '-'}</p>
|
||||
@@ -717,13 +714,13 @@ export default function ReceptionListPage() {
|
||||
<p className="fs11">{o.giftSended || o.giftDate || ''}</p>
|
||||
</td>
|
||||
<td className="col-gift">
|
||||
<p><span className={giftBadgeClass(o.giftStatus)}>{o.giftStatus || ''}</span></p>
|
||||
<p><span className={giftBadgeClass(o.giftStatus)}>{o.giftStatusLabel || giftLabel(o.giftStatus) || ''}</span></p>
|
||||
<p>{num(giftAmt)}</p>
|
||||
</td>
|
||||
<td className="col-cstatus floating-cell">
|
||||
{o.centerStatus || o.settleStatus || ''}
|
||||
{o.centerStatusLabel || centerLabel(o.centerStatus || o.settleStatus) || ''}
|
||||
<div className="floating-layer right">
|
||||
<p>정산상태: {o.settleStatus || '-'}</p>
|
||||
<p>입력처정산: {o.centerStatusLabel || centerLabel(o.settleStatus) || '-'}</p>
|
||||
<p>개통일: {o.opened || o.openDate || '-'}</p>
|
||||
<p>해지일: {o.canceled || o.cancelDate || '-'}</p>
|
||||
</div>
|
||||
@@ -788,13 +785,13 @@ export default function ReceptionListPage() {
|
||||
<Form.Item name="customerName" label="고객명" rules={[{ required: true }]}><Input /></Form.Item>
|
||||
<Form.Item name="phone" label="연락처"><Input /></Form.Item>
|
||||
<Form.Item name="progressStatus" label="진행상황">
|
||||
<Select options={options(progressStatuses)} />
|
||||
<Select options={progressSelectOpts} />
|
||||
</Form.Item>
|
||||
<Form.Item name="settleStatus" label="정산상태">
|
||||
<Select options={options(settleStatuses)} />
|
||||
<Form.Item name="settleStatus" label="입력처정산">
|
||||
<Select options={settleSelectOpts} />
|
||||
</Form.Item>
|
||||
<Form.Item name="giftStatus" label="사은품진행">
|
||||
<Select options={options(giftStatuses)} />
|
||||
<Select options={giftSelectOpts} />
|
||||
</Form.Item>
|
||||
</div>
|
||||
<div className="balance-modal-footer">
|
||||
|
||||
@@ -416,7 +416,7 @@ export default function SettingAgencyPage() {
|
||||
loading={saveMut.isPending}
|
||||
icon={<EditOutlined />}
|
||||
>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Button, Card, Input, Select, Space, message } from 'antd'
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type Board = {
|
||||
id: number
|
||||
name?: string
|
||||
listLevel: number
|
||||
readLevel: number
|
||||
writeLevel: number
|
||||
seq?: number
|
||||
isOn?: number | boolean
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
const LEVELS = [
|
||||
{ value: 1, label: '영업점(1)' },
|
||||
{ value: 5, label: '센터(5)' },
|
||||
{ value: 9, label: '관리자(9)' },
|
||||
{ value: 10, label: '최고관리자(10)' },
|
||||
]
|
||||
|
||||
const empty = { name: '', listLevel: 1, readLevel: 1, writeLevel: 9 }
|
||||
|
||||
const lvlLabel = (v: number) => LEVELS.find((x) => x.value === v)?.label || String(v)
|
||||
|
||||
export default function SettingBoardAdminPage() {
|
||||
const qc = useQueryClient()
|
||||
const [form, setForm] = useState<{ id?: number; name: string; listLevel: number; readLevel: number; writeLevel: number }>(empty)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-boards'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Board[]>>(`${apiPrefix()}/homes/boards`)
|
||||
return unwrap(data) || []
|
||||
},
|
||||
})
|
||||
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['homes-boards'] })
|
||||
const reset = () => setForm(empty)
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const body = {
|
||||
name: form.name,
|
||||
listLevel: form.listLevel,
|
||||
readLevel: form.readLevel,
|
||||
writeLevel: form.writeLevel,
|
||||
}
|
||||
if (form.id) {
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/boards/${form.id}`, body)
|
||||
return unwrap(data as ApiResponse)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/boards`, body)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(form.id ? '수정했습니다.' : '추가했습니다.')
|
||||
reset()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.delete(`${apiPrefix()}/homes/boards/${id}`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '순서',
|
||||
dataIndex: 'seq',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
render: (v?: number) => v ?? '-',
|
||||
},
|
||||
{ title: '게시판명', dataIndex: 'name', ellipsis: true },
|
||||
{
|
||||
title: '목록권한',
|
||||
dataIndex: 'listLevel',
|
||||
width: 120,
|
||||
render: (v: number) => lvlLabel(v),
|
||||
},
|
||||
{
|
||||
title: '읽기권한',
|
||||
dataIndex: 'readLevel',
|
||||
width: 120,
|
||||
render: (v: number) => lvlLabel(v),
|
||||
},
|
||||
{
|
||||
title: '쓰기권한',
|
||||
dataIndex: 'writeLevel',
|
||||
width: 130,
|
||||
render: (v: number) => lvlLabel(v),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
width: 140,
|
||||
render: (_: unknown, row: Board) => (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="branch-row-btn"
|
||||
onClick={() =>
|
||||
setForm({
|
||||
id: row.id,
|
||||
name: row.name || '',
|
||||
listLevel: row.listLevel,
|
||||
readLevel: row.readLevel,
|
||||
writeLevel: row.writeLevel,
|
||||
})
|
||||
}
|
||||
>
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => {
|
||||
if (window.confirm('삭제하시겠습니까?')) removeMut.mutate(row.id)
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[removeMut],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="stock-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<SettingOutlined className="product-title-icon" />
|
||||
게시판관리
|
||||
</h2>
|
||||
<p>게시판 정의와 목록/읽기/쓰기 권한 레벨을 관리합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card size="small" style={{ marginBottom: 12 }}>
|
||||
<Space wrap align="end">
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ant-color-text-secondary)', marginBottom: 4 }}>게시판명</div>
|
||||
<Input
|
||||
placeholder="게시판명"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ant-color-text-secondary)', marginBottom: 4 }}>목록 권한</div>
|
||||
<Select
|
||||
style={{ width: 140 }}
|
||||
value={form.listLevel}
|
||||
onChange={(v) => setForm({ ...form, listLevel: v })}
|
||||
options={LEVELS}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ant-color-text-secondary)', marginBottom: 4 }}>읽기 권한</div>
|
||||
<Select
|
||||
style={{ width: 140 }}
|
||||
value={form.readLevel}
|
||||
onChange={(v) => setForm({ ...form, readLevel: v })}
|
||||
options={LEVELS}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div style={{ fontSize: 11, color: 'var(--ant-color-text-secondary)', marginBottom: 4 }}>쓰기 권한</div>
|
||||
<Select
|
||||
style={{ width: 150 }}
|
||||
value={form.writeLevel}
|
||||
onChange={(v) => setForm({ ...form, writeLevel: v })}
|
||||
options={LEVELS}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!form.name.trim()}
|
||||
loading={saveMut.isPending}
|
||||
onClick={() => saveMut.mutate()}
|
||||
>
|
||||
{form.id ? '수정' : '추가'}
|
||||
</Button>
|
||||
{form.id && <Button onClick={reset}>취소</Button>}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card size="small">
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data || []}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '등록된 게시판이 없습니다.' }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,388 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Button, Card, Form, Input, Space, Typography, message } from 'antd'
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import DraggableModal from '../../components/DraggableModal'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type CenterRow = {
|
||||
id: number
|
||||
name?: string
|
||||
tel?: string
|
||||
phone?: string
|
||||
manager?: string
|
||||
seq?: number
|
||||
isOn?: number | boolean
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
type QdCenter = {
|
||||
id: number
|
||||
name?: string
|
||||
authorized?: boolean
|
||||
baseUrl?: string | null
|
||||
}
|
||||
|
||||
const isActive = (row: { isOn?: number | boolean; active?: boolean }) =>
|
||||
row.isOn === 1 || row.isOn === true || row.active === true
|
||||
|
||||
export default function SettingCenterPage() {
|
||||
const qc = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const [editForm] = Form.useForm()
|
||||
const [authForm] = Form.useForm()
|
||||
const [editTarget, setEditTarget] = useState<CenterRow | null>(null)
|
||||
const [authTarget, setAuthTarget] = useState<(QdCenter & { mode: 'auth' | 'reauth' }) | null>(null)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-input-centers'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<CenterRow[]>>(`${apiPrefix()}/homes/input-centers`)
|
||||
return unwrap(data) || []
|
||||
},
|
||||
})
|
||||
|
||||
const qdQuery = useQuery({
|
||||
queryKey: ['homes-qd-centers'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const { data } = await client.get<ApiResponse<QdCenter[]>>(
|
||||
`${apiPrefix()}/homes/quick-delivery/centers`,
|
||||
)
|
||||
return unwrap(data) || []
|
||||
} catch {
|
||||
return [] as QdCenter[]
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
const qdMap = useMemo(
|
||||
() => new Map((qdQuery.data || []).map((c) => [c.id, c])),
|
||||
[qdQuery.data],
|
||||
)
|
||||
|
||||
const refresh = () => {
|
||||
qc.invalidateQueries({ queryKey: ['homes-input-centers'] })
|
||||
qc.invalidateQueries({ queryKey: ['homes-qd-centers'] })
|
||||
}
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: async (values: { name: string; tel?: string; manager?: string }) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/input-centers`, values)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
form.resetFields()
|
||||
message.success('추가했습니다.')
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async (values: { name: string; tel?: string; manager?: string }) => {
|
||||
const { data } = await client.put(
|
||||
`${apiPrefix()}/homes/input-centers/${editTarget!.id}`,
|
||||
values,
|
||||
)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('수정했습니다.')
|
||||
setEditTarget(null)
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const onoffMut = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/input-centers/${id}/onoff`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => refresh(),
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const moveMut = useMutation({
|
||||
mutationFn: async ({ id, dir }: { id: number; dir: 'up' | 'down' }) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/input-centers/${id}/${dir}`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => refresh(),
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.delete(`${apiPrefix()}/homes/input-centers/${id}`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const authMut = useMutation({
|
||||
mutationFn: async (values: { userid: string; password: string; baseUrl?: string }) => {
|
||||
const id = authTarget!.id
|
||||
if (authTarget!.mode === 'reauth') {
|
||||
const { data } = await client.post(
|
||||
`${apiPrefix()}/homes/quick-delivery/center/${id}/reauth`,
|
||||
{ userid: values.userid, password: values.password },
|
||||
)
|
||||
return unwrap(data as ApiResponse)
|
||||
}
|
||||
const { data } = await client.post(
|
||||
`${apiPrefix()}/homes/quick-delivery/center/${id}/auth`,
|
||||
values,
|
||||
)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('완료되었습니다.')
|
||||
setAuthTarget(null)
|
||||
authForm.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const removeAuthMut = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.delete(`${apiPrefix()}/homes/quick-delivery/center/${id}/auth`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('인증을 해제했습니다.')
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '순서',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: CenterRow, idx: number) => (
|
||||
<Space size={4}>
|
||||
<Button size="small" disabled={idx === 0} onClick={() => moveMut.mutate({ id: row.id, dir: 'up' })}>
|
||||
▲
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={idx === (query.data?.length ?? 0) - 1}
|
||||
onClick={() => moveMut.mutate({ id: row.id, dir: 'down' })}
|
||||
>
|
||||
▼
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ title: '센터명', dataIndex: 'name', ellipsis: true },
|
||||
{ title: '연락처', dataIndex: 'tel', width: 130, render: (v?: string) => v || '' },
|
||||
{ title: '담당자', dataIndex: 'manager', width: 100, render: (v?: string) => v || '' },
|
||||
{
|
||||
title: '상태',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: CenterRow) => (
|
||||
<Button
|
||||
size="small"
|
||||
type={isActive(row) ? 'primary' : 'default'}
|
||||
className="branch-row-btn"
|
||||
onClick={() => onoffMut.mutate(row.id)}
|
||||
>
|
||||
{isActive(row) ? '사용' : '중지'}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '퀵전송',
|
||||
width: 180,
|
||||
render: (_: unknown, row: CenterRow) => {
|
||||
const qd = qdMap.get(row.id)
|
||||
return (
|
||||
<Space size={4} wrap>
|
||||
{qd?.authorized ? (
|
||||
<Typography.Text type="success">인증됨</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text type="secondary">미인증</Typography.Text>
|
||||
)}
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setAuthTarget({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
baseUrl: qd?.baseUrl,
|
||||
authorized: qd?.authorized,
|
||||
mode: qd?.authorized ? 'reauth' : 'auth',
|
||||
})
|
||||
authForm.setFieldsValue({
|
||||
baseUrl: qd?.baseUrl || '',
|
||||
userid: '',
|
||||
password: '',
|
||||
})
|
||||
}}
|
||||
>
|
||||
{qd?.authorized ? '재인증' : '인증'}
|
||||
</Button>
|
||||
{qd?.authorized && (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => {
|
||||
if (window.confirm('인증을 해제하시겠습니까?')) removeAuthMut.mutate(row.id)
|
||||
}}
|
||||
>
|
||||
해제
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
width: 220,
|
||||
render: (_: unknown, row: CenterRow) => (
|
||||
<Space size={4} wrap>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="branch-row-btn"
|
||||
onClick={() => {
|
||||
setEditTarget(row)
|
||||
editForm.setFieldsValue({
|
||||
name: row.name,
|
||||
tel: row.tel,
|
||||
manager: row.manager,
|
||||
})
|
||||
}}
|
||||
>
|
||||
수정
|
||||
</Button>
|
||||
<Link to={`/care/setting/fee?tab=center&targetId=${row.id}`}>
|
||||
<Button size="small" type="primary" className="branch-row-btn">
|
||||
수수료
|
||||
</Button>
|
||||
</Link>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => {
|
||||
if (window.confirm('삭제하시겠습니까?')) removeMut.mutate(row.id)
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[query.data, qdMap, moveMut, onoffMut, removeMut, removeAuthMut, editForm, authForm],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="stock-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<SettingOutlined className="product-title-icon" />
|
||||
입력처관리
|
||||
</h2>
|
||||
<p>센터(입력처)를 등록하고 퀵전송 인증·수수료를 관리합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card size="small" style={{ marginBottom: 12 }}>
|
||||
<Form form={form} layout="inline" onFinish={(v) => createMut.mutate(v)}>
|
||||
<Form.Item name="name" rules={[{ required: true, message: '센터명' }]}>
|
||||
<Input placeholder="센터명" />
|
||||
</Form.Item>
|
||||
<Form.Item name="tel">
|
||||
<Input placeholder="연락처" />
|
||||
</Form.Item>
|
||||
<Form.Item name="manager">
|
||||
<Input placeholder="담당자" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={createMut.isPending}>
|
||||
추가
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card size="small">
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data || []}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '등록된 입력처가 없습니다.' }}
|
||||
scroll={{ x: 900 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<DraggableModal
|
||||
title="입력처 수정"
|
||||
open={!!editTarget}
|
||||
onCancel={() => setEditTarget(null)}
|
||||
onOk={() => editForm.submit()}
|
||||
confirmLoading={saveMut.isPending}
|
||||
destroyOnClose
|
||||
width={420}
|
||||
>
|
||||
<Form form={editForm} layout="vertical" onFinish={(v) => saveMut.mutate(v)}>
|
||||
<Form.Item name="name" label="센터명" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="tel" label="연락처">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="manager" label="담당자">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</DraggableModal>
|
||||
|
||||
<DraggableModal
|
||||
title={`${authTarget?.name || ''} — ${authTarget?.mode === 'reauth' ? '재인증' : '인증'}`}
|
||||
open={!!authTarget}
|
||||
onCancel={() => setAuthTarget(null)}
|
||||
onOk={() => authForm.submit()}
|
||||
confirmLoading={authMut.isPending}
|
||||
destroyOnClose
|
||||
width={440}
|
||||
>
|
||||
<Form form={authForm} layout="vertical" onFinish={(v) => authMut.mutate(v)}>
|
||||
{authTarget?.mode === 'auth' && (
|
||||
<Form.Item
|
||||
name="baseUrl"
|
||||
label="상부점 기본 URL"
|
||||
rules={[{ required: true, message: 'URL을 입력하세요.' }]}
|
||||
>
|
||||
<Input placeholder="https://upper.example.com" />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="userid" label="상부점 아이디" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="상부점 비밀번호" rules={[{ required: true }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</DraggableModal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,302 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Checkbox,
|
||||
Form,
|
||||
Input,
|
||||
Space,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
const TOGGLES: [string, string][] = [
|
||||
['order_disabled', '접수 등록 잠금(최근 2개월만)'],
|
||||
['personal_info_conceal', '개인정보 숨김'],
|
||||
['personal_info_conceal_in_order_list', '접수목록 개인정보 숨김'],
|
||||
['exceldown_button_hide', '엑셀 다운로드 버튼 숨김'],
|
||||
['gift_button_hide', '사은품 버튼 숨김'],
|
||||
]
|
||||
|
||||
const chk = (v?: string) => v === 'true'
|
||||
|
||||
export default function SettingConfigurePage() {
|
||||
const qc = useQueryClient()
|
||||
const [config, setConfig] = useState<Record<string, string>>({})
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-configure'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Record<string, string>>>(
|
||||
`${apiPrefix()}/homes/configure`,
|
||||
)
|
||||
return unwrap(data) || {}
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (query.data) setConfig(query.data)
|
||||
}, [query.data])
|
||||
|
||||
const set = (key: string, value: string) => setConfig((c) => ({ ...c, [key]: value }))
|
||||
const setChk = (key: string, checked: boolean) => set(key, checked ? 'true' : 'false')
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/configure`, config)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('저장되었습니다.')
|
||||
qc.invalidateQueries({ queryKey: ['homes-configure'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const toggleMut = useMutation({
|
||||
mutationFn: async (key: string) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/configure/toggle/${key}`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['homes-configure'] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const field = (
|
||||
label: string,
|
||||
children: ReactNode,
|
||||
hint?: string,
|
||||
) => (
|
||||
<Form.Item label={label} style={{ marginBottom: 16 }}>
|
||||
{children}
|
||||
{hint && (
|
||||
<Typography.Paragraph type="secondary" style={{ margin: '4px 0 0', fontSize: 12 }}>
|
||||
{hint}
|
||||
</Typography.Paragraph>
|
||||
)}
|
||||
</Form.Item>
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="stock-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<SettingOutlined className="product-title-icon" />
|
||||
기타 환경 설정
|
||||
</h2>
|
||||
<p>사이트·알림·IP·기능 토글 등 기타 설정을 관리합니다.</p>
|
||||
</div>
|
||||
<Space>
|
||||
<Button type="primary" loading={saveMut.isPending} onClick={() => saveMut.mutate()}>
|
||||
저장
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card size="small" loading={query.isLoading} style={{ maxWidth: 900, marginBottom: 16 }}>
|
||||
<Form layout="vertical">
|
||||
{field(
|
||||
'사이트명',
|
||||
<Input
|
||||
value={config.sitename || ''}
|
||||
onChange={(e) => set('sitename', e.target.value)}
|
||||
style={{ maxWidth: 320 }}
|
||||
/>,
|
||||
)}
|
||||
{field(
|
||||
'고객센터번호',
|
||||
<Input
|
||||
value={config.tel || ''}
|
||||
onChange={(e) => set('tel', e.target.value)}
|
||||
style={{ maxWidth: 220 }}
|
||||
/>,
|
||||
'로그인 페이지에 표시될 고객센터 번호입니다.',
|
||||
)}
|
||||
{field(
|
||||
'FAX',
|
||||
<Input
|
||||
value={config.fax || ''}
|
||||
onChange={(e) => set('fax', e.target.value)}
|
||||
style={{ maxWidth: 220 }}
|
||||
/>,
|
||||
)}
|
||||
{field(
|
||||
'SMS 발신번호',
|
||||
<Input
|
||||
value={config.sms_callback || ''}
|
||||
onChange={(e) => set('sms_callback', e.target.value)}
|
||||
style={{ maxWidth: 400 }}
|
||||
/>,
|
||||
'SMS 발신번호는 등록 후 사용 가능합니다.',
|
||||
)}
|
||||
{field(
|
||||
'엑셀 다운로드 이메일',
|
||||
<Input
|
||||
value={config.excel_down_email || ''}
|
||||
onChange={(e) => set('excel_down_email', e.target.value)}
|
||||
style={{ maxWidth: 280 }}
|
||||
/>,
|
||||
'설정 시 엑셀 생성 후 해당 주소로 파일이 발송됩니다.',
|
||||
)}
|
||||
{field(
|
||||
'로고 경로',
|
||||
<Input
|
||||
value={config.logo_path || ''}
|
||||
onChange={(e) => set('logo_path', e.target.value)}
|
||||
placeholder="/path/to/logo.png"
|
||||
style={{ maxWidth: 400 }}
|
||||
/>,
|
||||
'파일 업로드는 지원하지 않습니다. 로고 경로를 텍스트로 설정하세요.',
|
||||
)}
|
||||
{field(
|
||||
'회원가입 알림',
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Input
|
||||
value={config.signup_sms_destno || ''}
|
||||
onChange={(e) => set('signup_sms_destno', e.target.value)}
|
||||
placeholder="전화번호 (콤마 구분)"
|
||||
style={{ maxWidth: 360 }}
|
||||
/>
|
||||
<Input
|
||||
value={config.signup_sms_message || ''}
|
||||
onChange={(e) => set('signup_sms_message', e.target.value)}
|
||||
placeholder="발송 메시지 (@영업점 치환)"
|
||||
style={{ maxWidth: 480 }}
|
||||
/>
|
||||
</Space>,
|
||||
)}
|
||||
{field(
|
||||
'접수등록 알림',
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Input
|
||||
value={config.order_created_sms_destno || ''}
|
||||
onChange={(e) => set('order_created_sms_destno', e.target.value)}
|
||||
placeholder="전화번호 (콤마 구분)"
|
||||
style={{ maxWidth: 360 }}
|
||||
/>
|
||||
<Input
|
||||
value={config.order_created_sms_message || ''}
|
||||
onChange={(e) => set('order_created_sms_message', e.target.value)}
|
||||
placeholder="발송 메시지 (@영업점 치환)"
|
||||
style={{ maxWidth: 480 }}
|
||||
/>
|
||||
</Space>,
|
||||
)}
|
||||
{field(
|
||||
'승인 알림',
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Checkbox
|
||||
checked={chk(config.branch_granted_sms)}
|
||||
onChange={(e) => setChk('branch_granted_sms', e.target.checked)}
|
||||
>
|
||||
승인 메시지를 발송합니다.
|
||||
</Checkbox>
|
||||
<Input
|
||||
value={config.branch_granted_message || ''}
|
||||
onChange={(e) => set('branch_granted_message', e.target.value)}
|
||||
style={{ maxWidth: 480 }}
|
||||
/>
|
||||
</Space>,
|
||||
)}
|
||||
{field(
|
||||
'접속 허용 IP',
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
value={config.ip_list || ''}
|
||||
onChange={(e) => set('ip_list', e.target.value)}
|
||||
placeholder="공백 또는 콤마로 구분"
|
||||
/>,
|
||||
'관리자 접속 시에만 적용됩니다. 비우면 제한 없음.',
|
||||
)}
|
||||
{field(
|
||||
'IP 정책 무시 아이디',
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
value={config.enable_ip_userid || ''}
|
||||
onChange={(e) => set('enable_ip_userid', e.target.value)}
|
||||
placeholder="공백 또는 콤마로 구분"
|
||||
/>,
|
||||
)}
|
||||
{field(
|
||||
'회원가입 약관',
|
||||
<Input.TextArea
|
||||
rows={6}
|
||||
value={config.agreement || ''}
|
||||
onChange={(e) => set('agreement', e.target.value)}
|
||||
/>,
|
||||
)}
|
||||
{field(
|
||||
'로그인 소스 변경',
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Checkbox
|
||||
checked={chk(config.login_ui_apply)}
|
||||
onChange={(e) => setChk('login_ui_apply', e.target.checked)}
|
||||
>
|
||||
로그인 소스를 적용합니다.
|
||||
</Checkbox>
|
||||
<Input.TextArea
|
||||
rows={6}
|
||||
value={config.login_ui || ''}
|
||||
onChange={(e) => set('login_ui', e.target.value)}
|
||||
/>
|
||||
</Space>,
|
||||
)}
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="SMS 게이트웨이 (nBill)" style={{ maxWidth: 900, marginBottom: 16 }}>
|
||||
<Form layout="vertical">
|
||||
{field(
|
||||
'회사키(company_key)',
|
||||
<Input
|
||||
value={config.company_key || ''}
|
||||
onChange={(e) => set('company_key', e.target.value)}
|
||||
/>,
|
||||
)}
|
||||
{field(
|
||||
'관제 서버 URL(manager_url)',
|
||||
<Input
|
||||
value={config.manager_url || ''}
|
||||
onChange={(e) => set('manager_url', e.target.value)}
|
||||
/>,
|
||||
)}
|
||||
<Button type="primary" loading={saveMut.isPending} onClick={() => saveMut.mutate()}>
|
||||
저장
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="기능 토글" style={{ maxWidth: 900 }}>
|
||||
{TOGGLES.map(([key, label]) => (
|
||||
<div
|
||||
key={key}
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
padding: '8px 0',
|
||||
borderBottom: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
|
||||
}}
|
||||
>
|
||||
<span>{label}</span>
|
||||
<Button
|
||||
size="small"
|
||||
type={config[key] === 'true' ? 'primary' : 'default'}
|
||||
className="branch-row-btn"
|
||||
loading={toggleMut.isPending}
|
||||
onClick={() => toggleMut.mutate(key)}
|
||||
>
|
||||
{config[key] === 'true' ? 'ON' : 'OFF'}
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Space,
|
||||
Tabs,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd'
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
import dayjs from 'dayjs'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import DraggableModal from '../../components/DraggableModal'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type TabKey = 'center' | 'group' | 'branch'
|
||||
|
||||
type Target = { id: number; name: string }
|
||||
type Isp = { id: number; name?: string; telecom?: string }
|
||||
type FeeSheet = {
|
||||
id: number
|
||||
type?: string
|
||||
ispId?: number
|
||||
ispName?: string
|
||||
carrier?: string
|
||||
memo?: string
|
||||
startDate?: string
|
||||
category?: string
|
||||
}
|
||||
type FeeProduct = { id?: number; productType: string; productName: string; fee: number }
|
||||
type FeeDetail = {
|
||||
fee: {
|
||||
id: number
|
||||
type?: string
|
||||
carrier?: string
|
||||
ispName?: string
|
||||
startDate?: string
|
||||
memo?: string
|
||||
}
|
||||
products: FeeProduct[]
|
||||
}
|
||||
|
||||
const PRODUCT_TYPE: Record<string, string> = {
|
||||
e01: '인터넷',
|
||||
e02: '전화',
|
||||
e03: 'TV',
|
||||
e04: '렌탈',
|
||||
}
|
||||
|
||||
async function loadTargets(tab: TabKey): Promise<Target[]> {
|
||||
try {
|
||||
const { data } = await client.get<ApiResponse<Target[]>>(
|
||||
`${apiPrefix()}/homes/fee-targets/${tab}`,
|
||||
)
|
||||
const rows = unwrap(data)
|
||||
if (rows?.length) return rows
|
||||
} catch {
|
||||
/* fallback below */
|
||||
}
|
||||
if (tab === 'center') {
|
||||
const { data } = await client.get<ApiResponse<{ id: number; name?: string }[]>>(
|
||||
`${apiPrefix()}/homes/input-centers`,
|
||||
)
|
||||
return (unwrap(data) || []).map((r) => ({ id: r.id, name: r.name || `#${r.id}` }))
|
||||
}
|
||||
if (tab === 'group') {
|
||||
const { data } = await client.get<ApiResponse<{ id: number; name?: string }[]>>(
|
||||
`${apiPrefix()}/homes/branch-groups`,
|
||||
)
|
||||
return (unwrap(data) || []).map((r) => ({ id: r.id, name: r.name || `#${r.id}` }))
|
||||
}
|
||||
const { data } = await client.get<ApiResponse<{ id: number; name?: string; officeName?: string }[]>>(
|
||||
`${apiPrefix()}/homes/branches`,
|
||||
)
|
||||
return (unwrap(data) || []).map((r) => ({
|
||||
id: r.id,
|
||||
name: r.officeName || r.name || `#${r.id}`,
|
||||
}))
|
||||
}
|
||||
|
||||
function listUrl(tab: TabKey, tid: number) {
|
||||
if (tab === 'center') return `${apiPrefix()}/homes/center-fees/center/${tid}`
|
||||
if (tab === 'group') return `${apiPrefix()}/homes/branch-groups/${tid}/fees`
|
||||
return `${apiPrefix()}/homes/branches/${tid}/fees`
|
||||
}
|
||||
|
||||
function detailBase(tab: TabKey) {
|
||||
if (tab === 'center') return `${apiPrefix()}/homes/center-fees`
|
||||
if (tab === 'group') return `${apiPrefix()}/homes/branch-group-fees`
|
||||
return `${apiPrefix()}/homes/branch-fees`
|
||||
}
|
||||
|
||||
export default function SettingFeePage() {
|
||||
const [sp] = useSearchParams()
|
||||
const initTab = (sp.get('tab') as TabKey) || 'center'
|
||||
const initTarget = sp.get('targetId') ? Number(sp.get('targetId')) : null
|
||||
const [tab, setTab] = useState<TabKey>(
|
||||
['center', 'group', 'branch'].includes(initTab) ? initTab : 'center',
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="stock-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<SettingOutlined className="product-title-icon" />
|
||||
수수료관리
|
||||
</h2>
|
||||
<p>입력처·그룹·영업점 수수료 시트를 관리합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card size="small">
|
||||
<Tabs
|
||||
activeKey={tab}
|
||||
onChange={(k) => setTab(k as TabKey)}
|
||||
items={[
|
||||
{
|
||||
key: 'center',
|
||||
label: '센터 수수료',
|
||||
children: (
|
||||
<FeeSection
|
||||
tab="center"
|
||||
initialTargetId={initTab === 'center' ? initTarget : null}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'group',
|
||||
label: '그룹 수수료',
|
||||
children: (
|
||||
<FeeSection
|
||||
tab="group"
|
||||
initialTargetId={initTab === 'group' ? initTarget : null}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'branch',
|
||||
label: '개별(영업점) 수수료',
|
||||
children: (
|
||||
<FeeSection
|
||||
tab="branch"
|
||||
initialTargetId={initTab === 'branch' ? initTarget : null}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FeeSection({
|
||||
tab,
|
||||
initialTargetId = null,
|
||||
}: {
|
||||
tab: TabKey
|
||||
initialTargetId?: number | null
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const [targetId, setTargetId] = useState<number | null>(initialTargetId)
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [detailOpen, setDetailOpen] = useState(false)
|
||||
const [detailFeeId, setDetailFeeId] = useState<number | null>(null)
|
||||
const [detailType, setDetailType] = useState<'alone' | 'group'>('alone')
|
||||
const [editable, setEditable] = useState(false)
|
||||
const [memo, setMemo] = useState('')
|
||||
const [startDate, setStartDate] = useState('')
|
||||
const [products, setProducts] = useState<FeeProduct[]>([])
|
||||
const [createForm] = Form.useForm()
|
||||
|
||||
const targetsQuery = useQuery({
|
||||
queryKey: ['homes-fee-targets', tab],
|
||||
queryFn: () => loadTargets(tab),
|
||||
})
|
||||
|
||||
const sheetsQuery = useQuery({
|
||||
queryKey: ['homes-fee-sheets', tab, targetId],
|
||||
enabled: targetId != null,
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<FeeSheet[]>>(listUrl(tab, targetId!))
|
||||
return unwrap(data) || []
|
||||
},
|
||||
})
|
||||
|
||||
const ispQuery = useQuery({
|
||||
queryKey: ['homes-isp'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Isp[]>>(`${apiPrefix()}/homes/isp`)
|
||||
return unwrap(data) || []
|
||||
},
|
||||
})
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: ['homes-fee-detail', tab, detailFeeId, detailType],
|
||||
enabled: !!detailFeeId && detailOpen,
|
||||
queryFn: async () => {
|
||||
const base =
|
||||
tab === 'group' || (tab === 'branch' && detailType === 'group')
|
||||
? `${apiPrefix()}/homes/branch-group-fees`
|
||||
: detailBase(tab)
|
||||
const params = tab === 'branch' && detailType === 'alone' ? { type: 'alone' } : undefined
|
||||
const { data } = await client.get<ApiResponse<FeeDetail>>(
|
||||
`${base}/${detailFeeId}`,
|
||||
{ params },
|
||||
)
|
||||
return unwrap(data)
|
||||
},
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!detailQuery.data) return
|
||||
setMemo(detailQuery.data.fee.memo || '')
|
||||
setStartDate(detailQuery.data.fee.startDate || '')
|
||||
setProducts((detailQuery.data.products || []).map((p) => ({ ...p, fee: p.fee ?? 0 })))
|
||||
if (tab === 'branch') setEditable(detailQuery.data.fee.type === 'alone' || detailType === 'alone')
|
||||
else setEditable(true)
|
||||
}, [detailQuery.data, tab, detailType])
|
||||
|
||||
const ispLabel = (row: FeeSheet) => {
|
||||
if (row.ispName || row.carrier) return row.ispName || row.carrier
|
||||
if (row.ispId) return ispQuery.data?.find((i) => i.id === row.ispId)?.name || row.ispId
|
||||
return '-'
|
||||
}
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: async (row: FeeSheet) => {
|
||||
const isGroup = tab === 'group' || row.type === 'group'
|
||||
const base = isGroup && tab !== 'center'
|
||||
? `${apiPrefix()}/homes/branch-group-fees`
|
||||
: detailBase(tab)
|
||||
const { data } = await client.delete(`${base}/${row.id}`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
qc.invalidateQueries({ queryKey: ['homes-fee-sheets', tab, targetId] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const body = {
|
||||
startDate,
|
||||
memo,
|
||||
carrier: detailQuery.data?.fee.carrier || detailQuery.data?.fee.ispName,
|
||||
products: products.map((p) => ({
|
||||
productType: p.productType,
|
||||
productName: p.productName,
|
||||
fee: p.fee ?? 0,
|
||||
})),
|
||||
}
|
||||
const base =
|
||||
tab === 'group' || detailType === 'group'
|
||||
? tab === 'center'
|
||||
? detailBase(tab)
|
||||
: `${apiPrefix()}/homes/branch-group-fees`
|
||||
: detailBase(tab)
|
||||
const { data } = await client.put(`${base}/${detailFeeId}`, body, {
|
||||
params: tab === 'branch' && detailType === 'alone' ? { type: 'alone' } : undefined,
|
||||
})
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('수정되었습니다.')
|
||||
qc.invalidateQueries({ queryKey: ['homes-fee-detail', tab, detailFeeId, detailType] })
|
||||
qc.invalidateQueries({ queryKey: ['homes-fee-sheets', tab, targetId] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const isp = ispQuery.data?.find((i) => i.id === values.ispId)
|
||||
const carrier = isp?.name || isp?.telecom || String(values.carrier || '')
|
||||
const sd = values.startDate
|
||||
const startDateStr =
|
||||
sd && typeof sd === 'object' && 'format' in (sd as object)
|
||||
? (sd as { format: (f: string) => string }).format('YYYY-MM-DD HH:mm:ss')
|
||||
: sd
|
||||
? dayjs(String(sd)).format('YYYY-MM-DD HH:mm:ss')
|
||||
: dayjs().format('YYYY-MM-DD 00:00:00')
|
||||
const productRows = (values.products as FeeProduct[]) || []
|
||||
const body = {
|
||||
ispId: values.ispId,
|
||||
carrier,
|
||||
startDate: startDateStr,
|
||||
memo: values.memo || '',
|
||||
products: productRows.map((p) => ({
|
||||
productType: p.productType,
|
||||
productName: p.productName,
|
||||
fee: Number(p.fee) || 0,
|
||||
})),
|
||||
}
|
||||
const { data } = await client.post(listUrl(tab, targetId!), body)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('등록되었습니다.')
|
||||
setCreateOpen(false)
|
||||
createForm.resetFields()
|
||||
qc.invalidateQueries({ queryKey: ['homes-fee-sheets', tab, targetId] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const openDetail = (row: FeeSheet, edit: boolean) => {
|
||||
const isGroup = row.type === 'group' || tab === 'group'
|
||||
setDetailType(isGroup ? 'group' : 'alone')
|
||||
setDetailFeeId(row.id)
|
||||
setDetailOpen(true)
|
||||
// 그룹 탭에서는 그룹 수수료 수정 가능. 개별 탭의 그룹 시트는 읽기 전용.
|
||||
setEditable(edit && (tab !== 'branch' || row.type !== 'group'))
|
||||
}
|
||||
|
||||
const sheets = useMemo(() => sheetsQuery.data || [], [sheetsQuery.data])
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space wrap style={{ marginBottom: 12 }}>
|
||||
<Select
|
||||
style={{ minWidth: 220 }}
|
||||
placeholder={`${tab === 'center' ? '센터' : tab === 'group' ? '그룹' : '영업점'} 선택`}
|
||||
value={targetId ?? undefined}
|
||||
onChange={(v) => {
|
||||
setTargetId(v)
|
||||
setCreateOpen(false)
|
||||
setDetailOpen(false)
|
||||
}}
|
||||
options={(targetsQuery.data || []).map((t) => ({ value: t.id, label: t.name }))}
|
||||
allowClear
|
||||
/>
|
||||
{targetId != null && (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
createForm.resetFields()
|
||||
createForm.setFieldsValue({
|
||||
startDate: dayjs().startOf('day'),
|
||||
memo: '',
|
||||
products: [{ productType: 'e01', productName: '', fee: 0 }],
|
||||
})
|
||||
setCreateOpen(true)
|
||||
}}
|
||||
>
|
||||
새 수수료 등록
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
|
||||
{targetId == null ? (
|
||||
<Typography.Text type="secondary">대상을 선택하세요.</Typography.Text>
|
||||
) : (
|
||||
<CareTable
|
||||
rowKey={(r) => `${r.type || 'alone'}-${r.id}`}
|
||||
size="small"
|
||||
loading={sheetsQuery.isLoading}
|
||||
dataSource={sheets}
|
||||
pagination={{ pageSize: 20, showSizeChanger: false }}
|
||||
locale={{ emptyText: '등록된 수수료가 없습니다.' }}
|
||||
columns={[
|
||||
{ title: 'ID', dataIndex: 'id', width: 70, align: 'center' as const },
|
||||
{
|
||||
title: '통신사',
|
||||
width: 120,
|
||||
render: (_: unknown, row: FeeSheet) => ispLabel(row),
|
||||
},
|
||||
{
|
||||
title: '적용일',
|
||||
dataIndex: 'startDate',
|
||||
width: 160,
|
||||
align: 'center' as const,
|
||||
render: (v?: string) => (v || '').slice(0, 16),
|
||||
},
|
||||
{
|
||||
title: '분류',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: FeeSheet) =>
|
||||
row.category || (row.type === 'group' ? '그룹' : tab === 'branch' ? '개별' : '-'),
|
||||
},
|
||||
{ title: '메모', dataIndex: 'memo', ellipsis: true },
|
||||
{
|
||||
title: '',
|
||||
width: 200,
|
||||
render: (_: unknown, row: FeeSheet) => (
|
||||
<Space size={4}>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="branch-row-btn"
|
||||
onClick={() => openDetail(row, false)}
|
||||
>
|
||||
상세
|
||||
</Button>
|
||||
{(row.type !== 'group' || tab !== 'branch') && (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="branch-row-btn"
|
||||
onClick={() => openDetail(row, true)}
|
||||
>
|
||||
수정
|
||||
</Button>
|
||||
)}
|
||||
{(row.type !== 'group' || tab !== 'branch') && (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => {
|
||||
if (window.confirm('삭제하시겠습니까?')) removeMut.mutate(row)
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DraggableModal
|
||||
title="수수료 등록"
|
||||
open={createOpen}
|
||||
onCancel={() => setCreateOpen(false)}
|
||||
onOk={() => createForm.submit()}
|
||||
confirmLoading={createMut.isPending}
|
||||
width={640}
|
||||
destroyOnClose
|
||||
okText="등록"
|
||||
>
|
||||
<Form form={createForm} layout="vertical" onFinish={(v) => createMut.mutate(v)}>
|
||||
<Form.Item name="ispId" label="통신사" rules={[{ required: true, message: '통신사를 선택하세요.' }]}>
|
||||
<Select
|
||||
options={(ispQuery.data || []).map((i) => ({
|
||||
value: i.id,
|
||||
label: i.name || i.telecom || `#${i.id}`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="startDate" label="적용일" rules={[{ required: true }]}>
|
||||
<DatePicker showTime style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="memo" label="메모">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.List name="products">
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
<div style={{ marginBottom: 8, display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography.Text strong>상품별 수수료</Typography.Text>
|
||||
<Button size="small" onClick={() => add({ productType: 'e01', productName: '', fee: 0 })}>
|
||||
행 추가
|
||||
</Button>
|
||||
</div>
|
||||
{fields.map((field) => (
|
||||
<Space key={field.key} align="start" style={{ display: 'flex', marginBottom: 8 }} wrap>
|
||||
<Form.Item {...field} name={[field.name, 'productType']} rules={[{ required: true }]}>
|
||||
<Select
|
||||
style={{ width: 100 }}
|
||||
options={Object.entries(PRODUCT_TYPE).map(([value, label]) => ({ value, label }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item {...field} name={[field.name, 'productName']} rules={[{ required: true, message: '상품명' }]}>
|
||||
<Input placeholder="상품명" style={{ width: 180 }} />
|
||||
</Form.Item>
|
||||
<Form.Item {...field} name={[field.name, 'fee']}>
|
||||
<InputNumber placeholder="수수료" style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Button size="small" danger onClick={() => remove(field.name)}>
|
||||
삭제
|
||||
</Button>
|
||||
</Space>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form>
|
||||
</DraggableModal>
|
||||
|
||||
<DraggableModal
|
||||
title={`수수료 상세${detailType === 'group' ? ' (그룹)' : ''}`}
|
||||
open={detailOpen}
|
||||
onCancel={() => {
|
||||
setDetailOpen(false)
|
||||
setDetailFeeId(null)
|
||||
}}
|
||||
width={680}
|
||||
destroyOnClose
|
||||
footer={(
|
||||
<Space>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDetailOpen(false)
|
||||
setDetailFeeId(null)
|
||||
}}
|
||||
>
|
||||
닫기
|
||||
</Button>
|
||||
{editable && (
|
||||
<Button type="primary" loading={saveMut.isPending} onClick={() => saveMut.mutate()}>
|
||||
저장
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
)}
|
||||
>
|
||||
{detailQuery.isLoading || !detailQuery.data ? (
|
||||
<Typography.Text type="secondary">불러오는 중...</Typography.Text>
|
||||
) : (
|
||||
<>
|
||||
<Space direction="vertical" style={{ width: '100%', marginBottom: 12 }}>
|
||||
<div>
|
||||
<Typography.Text type="secondary">판매처 · </Typography.Text>
|
||||
{detailQuery.data.fee.carrier || detailQuery.data.fee.ispName || '-'}
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary">적용일 · </Typography.Text>
|
||||
{editable ? (
|
||||
<DatePicker
|
||||
showTime
|
||||
value={startDate ? dayjs(startDate) : null}
|
||||
onChange={(d) => setStartDate(d ? d.format('YYYY-MM-DD HH:mm:ss') : '')}
|
||||
/>
|
||||
) : (
|
||||
detailQuery.data.fee.startDate || '-'
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Typography.Text type="secondary">메모 · </Typography.Text>
|
||||
{editable ? (
|
||||
<Input value={memo} onChange={(e) => setMemo(e.target.value)} />
|
||||
) : (
|
||||
detailQuery.data.fee.memo || '-'
|
||||
)}
|
||||
</div>
|
||||
</Space>
|
||||
<CareTable
|
||||
rowKey={(r) => `${r.productType}-${r.productName}`}
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={products}
|
||||
locale={{ emptyText: '상품별 수수료가 없습니다.' }}
|
||||
columns={[
|
||||
{
|
||||
title: '종류',
|
||||
dataIndex: 'productType',
|
||||
width: 80,
|
||||
render: (v: string) => PRODUCT_TYPE[v] || v,
|
||||
},
|
||||
{ title: '상품명', dataIndex: 'productName' },
|
||||
{
|
||||
title: '수수료',
|
||||
dataIndex: 'fee',
|
||||
width: 140,
|
||||
render: (v: number, _row: FeeProduct, idx: number) =>
|
||||
editable ? (
|
||||
<InputNumber
|
||||
value={v}
|
||||
style={{ width: '100%' }}
|
||||
onChange={(n) => {
|
||||
setProducts((prev) =>
|
||||
prev.map((p, i) => (i === idx ? { ...p, fee: Number(n) || 0 } : p)),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
`${(v ?? 0).toLocaleString('ko-KR')} 원`
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</DraggableModal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Button, Input, Space, message } from 'antd'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import DraggableModal from '../../components/DraggableModal'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type GroupRow = { id: number; name?: string; seq?: number }
|
||||
|
||||
const ROW_BTN = 'branch-row-btn isp-row-btn'
|
||||
|
||||
/** 통신사 그룹관리 팝업 */
|
||||
export default function IspGroupManageModal({
|
||||
open,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
}) {
|
||||
const qc = useQueryClient()
|
||||
const [name, setName] = useState('')
|
||||
const [editId, setEditId] = useState<number | null>(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-isp-group'],
|
||||
enabled: open,
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<GroupRow[]>>(`${apiPrefix()}/homes/isp-group`)
|
||||
return unwrap(data) || []
|
||||
},
|
||||
})
|
||||
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['homes-isp-group'] })
|
||||
|
||||
const addMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/isp-group`, { name })
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
setName('')
|
||||
message.success('추가했습니다.')
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/isp-group/${editId}`, { name: editName })
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
setEditId(null)
|
||||
message.success('저장했습니다.')
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.delete(`${apiPrefix()}/homes/isp-group/${id}`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
setName('')
|
||||
setEditId(null)
|
||||
setEditName('')
|
||||
onClose()
|
||||
}
|
||||
|
||||
return (
|
||||
<DraggableModal
|
||||
title="통신사 그룹관리"
|
||||
open={open}
|
||||
onCancel={handleClose}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
width={560}
|
||||
centered
|
||||
>
|
||||
<Space style={{ marginBottom: 12 }} wrap>
|
||||
<Input
|
||||
placeholder="새 그룹명"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
onPressEnter={() => { if (name.trim()) addMut.mutate() }}
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
className={ROW_BTN}
|
||||
disabled={!name.trim()}
|
||||
loading={addMut.isPending}
|
||||
onClick={() => addMut.mutate()}
|
||||
>
|
||||
추가
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data || []}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '등록된 그룹이 없습니다.' }}
|
||||
columns={[
|
||||
{
|
||||
title: '순서',
|
||||
dataIndex: 'seq',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
render: (v?: number) => v ?? '-',
|
||||
},
|
||||
{
|
||||
title: '그룹명',
|
||||
dataIndex: 'name',
|
||||
render: (_: unknown, row: GroupRow) =>
|
||||
editId === row.id ? (
|
||||
<Input
|
||||
value={editName}
|
||||
onChange={(e) => setEditName(e.target.value)}
|
||||
onPressEnter={() => saveMut.mutate()}
|
||||
/>
|
||||
) : (
|
||||
row.name
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
width: 140,
|
||||
render: (_: unknown, row: GroupRow) =>
|
||||
editId === row.id ? (
|
||||
<Space>
|
||||
<Button size="small" type="primary" className={ROW_BTN} onClick={() => saveMut.mutate()}>
|
||||
저장
|
||||
</Button>
|
||||
<Button size="small" onClick={() => setEditId(null)}>
|
||||
취소
|
||||
</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className={ROW_BTN}
|
||||
onClick={() => {
|
||||
setEditId(row.id)
|
||||
setEditName(row.name || '')
|
||||
}}
|
||||
>
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => {
|
||||
if (window.confirm('삭제하시겠습니까?')) removeMut.mutate(row.id)
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DraggableModal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Button, Card, Input, Select, Space, message } from 'antd'
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type Isp = { id: number; name?: string }
|
||||
type IspLink = {
|
||||
id: number
|
||||
name?: string
|
||||
url?: string
|
||||
ispId: number
|
||||
seq?: number
|
||||
isOn?: number | boolean
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
const isActive = (row: { isOn?: number | boolean; active?: boolean }) =>
|
||||
row.isOn === 1 || row.isOn === true || row.active === true
|
||||
|
||||
const empty = { name: '', url: '', ispId: 0 as number }
|
||||
|
||||
export default function SettingIspLinkPage() {
|
||||
const qc = useQueryClient()
|
||||
const [form, setForm] = useState<{ id?: number; name: string; url: string; ispId: number }>(empty)
|
||||
|
||||
const linksQuery = useQuery({
|
||||
queryKey: ['homes-isp-link'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<IspLink[]>>(`${apiPrefix()}/homes/isp-link`)
|
||||
return unwrap(data) || []
|
||||
},
|
||||
})
|
||||
|
||||
const ispQuery = useQuery({
|
||||
queryKey: ['homes-isp'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Isp[]>>(`${apiPrefix()}/homes/isp`)
|
||||
return unwrap(data) || []
|
||||
},
|
||||
})
|
||||
|
||||
const ispName = (id: number) => ispQuery.data?.find((i) => i.id === id)?.name || id
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['homes-isp-link'] })
|
||||
const reset = () => setForm(empty)
|
||||
const valid = form.name.trim() && form.url.trim() && form.ispId
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async () => {
|
||||
const body = { name: form.name, url: form.url, ispId: form.ispId }
|
||||
if (form.id) {
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/isp-link/${form.id}`, body)
|
||||
return unwrap(data as ApiResponse)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/isp-link`, body)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(form.id ? '수정했습니다.' : '추가했습니다.')
|
||||
reset()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.delete(`${apiPrefix()}/homes/isp-link/${id}`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const onoffMut = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/isp-link/${id}/onoff`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => refresh(),
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const moveMut = useMutation({
|
||||
mutationFn: async ({ id, dir }: { id: number; dir: 'up' | 'down' }) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/isp-link/${id}/${dir}`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => refresh(),
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '순서',
|
||||
width: 100,
|
||||
render: (_: unknown, row: IspLink) => (
|
||||
<Space size={4}>
|
||||
<span>{row.seq ?? '-'}</span>
|
||||
<Button size="small" onClick={() => moveMut.mutate({ id: row.id, dir: 'up' })}>
|
||||
▲
|
||||
</Button>
|
||||
<Button size="small" onClick={() => moveMut.mutate({ id: row.id, dir: 'down' })}>
|
||||
▼
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '통신사',
|
||||
width: 120,
|
||||
render: (_: unknown, row: IspLink) => ispName(row.ispId),
|
||||
},
|
||||
{ title: '인증링크명', dataIndex: 'name', ellipsis: true },
|
||||
{
|
||||
title: 'URL',
|
||||
dataIndex: 'url',
|
||||
ellipsis: true,
|
||||
render: (v?: string) =>
|
||||
v ? (
|
||||
<a href={v} target="_blank" rel="noreferrer">
|
||||
{v}
|
||||
</a>
|
||||
) : (
|
||||
''
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '상태',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: IspLink) => (
|
||||
<Button
|
||||
size="small"
|
||||
type={isActive(row) ? 'primary' : 'default'}
|
||||
className="branch-row-btn"
|
||||
onClick={() => onoffMut.mutate(row.id)}
|
||||
>
|
||||
{isActive(row) ? '사용' : '중지'}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
width: 140,
|
||||
render: (_: unknown, row: IspLink) => (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="branch-row-btn"
|
||||
onClick={() =>
|
||||
setForm({
|
||||
id: row.id,
|
||||
name: row.name || '',
|
||||
url: row.url || '',
|
||||
ispId: row.ispId,
|
||||
})
|
||||
}
|
||||
>
|
||||
수정
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => {
|
||||
if (window.confirm('삭제하시겠습니까?')) removeMut.mutate(row.id)
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[ispQuery.data, moveMut, onoffMut, removeMut],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="stock-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<SettingOutlined className="product-title-icon" />
|
||||
통신사인증링크
|
||||
</h2>
|
||||
<p>통신사별 인증 링크를 등록·관리합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Card size="small" style={{ marginBottom: 12 }}>
|
||||
<Space wrap>
|
||||
<Select
|
||||
style={{ width: 180 }}
|
||||
placeholder="통신사 선택"
|
||||
value={form.ispId || undefined}
|
||||
onChange={(v) => setForm({ ...form, ispId: v })}
|
||||
options={(ispQuery.data || []).map((i) => ({ value: i.id, label: i.name }))}
|
||||
/>
|
||||
<Input
|
||||
placeholder="인증링크명"
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
<Input
|
||||
placeholder="URL"
|
||||
value={form.url}
|
||||
onChange={(e) => setForm({ ...form, url: e.target.value })}
|
||||
style={{ minWidth: 280, flex: 1 }}
|
||||
/>
|
||||
<Button type="primary" disabled={!valid} loading={saveMut.isPending} onClick={() => saveMut.mutate()}>
|
||||
{form.id ? '수정' : '추가'}
|
||||
</Button>
|
||||
{form.id && (
|
||||
<Button onClick={reset}>
|
||||
취소
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card size="small">
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={linksQuery.isLoading}
|
||||
dataSource={linksQuery.data || []}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '등록된 인증링크가 없습니다.' }}
|
||||
scroll={{ x: 860 }}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,527 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
Space,
|
||||
message,
|
||||
} from 'antd'
|
||||
import { PlusSquareOutlined, SettingOutlined, TeamOutlined } from '@ant-design/icons'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import DraggableModal from '../../components/DraggableModal'
|
||||
import IspGroupManageModal from './SettingIspGroupPage'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type IspRow = {
|
||||
id: number
|
||||
telecom?: string
|
||||
name?: string
|
||||
seq?: number
|
||||
isOn?: number | boolean
|
||||
active?: boolean
|
||||
isSktb?: boolean
|
||||
}
|
||||
|
||||
type IspProduct = {
|
||||
id: number
|
||||
type?: string
|
||||
productType?: string
|
||||
name?: string
|
||||
isOn?: number | boolean
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
type IspAgreement = {
|
||||
id: number
|
||||
name?: string
|
||||
isOn?: number | boolean
|
||||
active?: boolean
|
||||
}
|
||||
|
||||
const TELECOMS = ['SK', 'KT', 'LG']
|
||||
const PRODUCT_TYPES = [
|
||||
{ value: 'e01', label: '인터넷' },
|
||||
{ value: 'e02', label: '전화' },
|
||||
{ value: 'e03', label: 'TV' },
|
||||
{ value: 'e04', label: '렌탈' },
|
||||
]
|
||||
|
||||
const ROW_BTN = 'branch-row-btn isp-row-btn'
|
||||
|
||||
const isActive = (row: { isOn?: number | boolean; active?: boolean }) =>
|
||||
row.isOn === 1 || row.isOn === true || row.active === true
|
||||
|
||||
const productTypeLabel = (t?: string) =>
|
||||
PRODUCT_TYPES.find((p) => p.value === t)?.label || t || '-'
|
||||
|
||||
export default function SettingIspPage() {
|
||||
const qc = useQueryClient()
|
||||
const [form] = Form.useForm()
|
||||
const [open, setOpen] = useState(false)
|
||||
const [editing, setEditing] = useState<IspRow | null>(null)
|
||||
const [productIsp, setProductIsp] = useState<IspRow | null>(null)
|
||||
const [agreementIsp, setAgreementIsp] = useState<IspRow | null>(null)
|
||||
const [groupOpen, setGroupOpen] = useState(false)
|
||||
const [productForm] = Form.useForm()
|
||||
const [agreementForm] = Form.useForm()
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-isp'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<IspRow[]>>(`${apiPrefix()}/homes/isp`)
|
||||
return unwrap(data) || []
|
||||
},
|
||||
})
|
||||
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['homes-isp'] })
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async (values: { telecom: string; name: string }) => {
|
||||
if (editing?.id) {
|
||||
const { data } = await client.put(`${apiPrefix()}/homes/isp/${editing.id}`, values)
|
||||
return unwrap(data as ApiResponse)
|
||||
}
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/isp`, values)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success(editing ? '수정했습니다.' : '등록했습니다.')
|
||||
setOpen(false)
|
||||
setEditing(null)
|
||||
form.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const onoffMut = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/isp/${id}/onoff`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => refresh(),
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const moveMut = useMutation({
|
||||
mutationFn: async ({ id, dir }: { id: number; dir: 'up' | 'down' }) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/isp/${id}/updown/${dir}`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => refresh(),
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.delete(`${apiPrefix()}/homes/isp/${id}`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const productsQuery = useQuery({
|
||||
queryKey: ['homes-isp-products', productIsp?.id],
|
||||
enabled: !!productIsp?.id,
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<IspProduct[]>>(
|
||||
`${apiPrefix()}/homes/isp/${productIsp!.id}/products`,
|
||||
)
|
||||
return unwrap(data) || []
|
||||
},
|
||||
})
|
||||
|
||||
const addProductMut = useMutation({
|
||||
mutationFn: async (values: { type: string; name: string }) => {
|
||||
const { data } = await client.post(`${apiPrefix()}/homes/isp/${productIsp!.id}/products`, {
|
||||
type: values.type,
|
||||
productType: values.type,
|
||||
name: values.name,
|
||||
})
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('상품을 추가했습니다.')
|
||||
productForm.resetFields()
|
||||
productForm.setFieldsValue({ type: 'e01' })
|
||||
qc.invalidateQueries({ queryKey: ['homes-isp-products', productIsp?.id] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const delProductMut = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.delete(`${apiPrefix()}/homes/isp/products/${id}`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
qc.invalidateQueries({ queryKey: ['homes-isp-products', productIsp?.id] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const agreementsQuery = useQuery({
|
||||
queryKey: ['homes-isp-agreements', agreementIsp?.id],
|
||||
enabled: !!agreementIsp?.id,
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<IspAgreement[]>>(
|
||||
`${apiPrefix()}/homes/isp/${agreementIsp!.id}/agreements`,
|
||||
)
|
||||
return unwrap(data) || []
|
||||
},
|
||||
})
|
||||
|
||||
const addAgreementMut = useMutation({
|
||||
mutationFn: async (values: { name: string }) => {
|
||||
const { data } = await client.post(
|
||||
`${apiPrefix()}/homes/isp/${agreementIsp!.id}/agreements`,
|
||||
values,
|
||||
)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('약정을 추가했습니다.')
|
||||
agreementForm.resetFields()
|
||||
qc.invalidateQueries({ queryKey: ['homes-isp-agreements', agreementIsp?.id] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const delAgreementMut = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.delete(`${apiPrefix()}/homes/isp/agreements/${id}`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('삭제했습니다.')
|
||||
qc.invalidateQueries({ queryKey: ['homes-isp-agreements', agreementIsp?.id] })
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const openCreate = () => {
|
||||
setEditing(null)
|
||||
form.setFieldsValue({ telecom: 'SK', name: '' })
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const openEdit = (row: IspRow) => {
|
||||
setEditing(row)
|
||||
form.setFieldsValue({ telecom: row.telecom || 'SK', name: row.name })
|
||||
setOpen(true)
|
||||
}
|
||||
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{ title: '본사', dataIndex: 'telecom', width: 70, align: 'center' as const },
|
||||
{ title: '통신사', dataIndex: 'name', ellipsis: true },
|
||||
{
|
||||
title: '수정',
|
||||
width: 72,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: IspRow) => (
|
||||
<Button size="small" type="primary" className={ROW_BTN} onClick={() => openEdit(row)}>
|
||||
수정
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '상품관리',
|
||||
width: 88,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: IspRow) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className={ROW_BTN}
|
||||
onClick={() => {
|
||||
setProductIsp(row)
|
||||
productForm.setFieldsValue({ type: 'e01', name: '' })
|
||||
}}
|
||||
>
|
||||
상품관리
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '이벤트관리',
|
||||
width: 96,
|
||||
align: 'center' as const,
|
||||
render: () => (
|
||||
<Button size="small" disabled className="branch-row-btn">
|
||||
이벤트
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '약정관리',
|
||||
width: 88,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: IspRow) => (
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className={ROW_BTN}
|
||||
onClick={() => {
|
||||
setAgreementIsp(row)
|
||||
agreementForm.resetFields()
|
||||
}}
|
||||
>
|
||||
약정관리
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '지사관리',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: () => (
|
||||
<Button size="small" disabled className="branch-row-btn">
|
||||
지사
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '출력순서',
|
||||
width: 90,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: IspRow) => (
|
||||
<Space size={4}>
|
||||
<Button size="small" onClick={() => moveMut.mutate({ id: row.id, dir: 'up' })}>
|
||||
▲
|
||||
</Button>
|
||||
<Button size="small" onClick={() => moveMut.mutate({ id: row.id, dir: 'down' })}>
|
||||
▼
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: 'ON/OFF',
|
||||
width: 80,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: IspRow) => (
|
||||
<Button
|
||||
size="small"
|
||||
type={isActive(row) ? 'primary' : 'default'}
|
||||
className={ROW_BTN}
|
||||
onClick={() => onoffMut.mutate(row.id)}
|
||||
>
|
||||
{isActive(row) ? 'ON' : 'OFF'}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '삭제',
|
||||
width: 72,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, row: IspRow) => (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
type="primary"
|
||||
className="branch-row-btn"
|
||||
onClick={() => {
|
||||
if (window.confirm('삭제하시겠습니까?')) removeMut.mutate(row.id)
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[moveMut, onoffMut, removeMut, productForm, agreementForm],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="stock-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<SettingOutlined className="product-title-icon" />
|
||||
통신사관리
|
||||
</h2>
|
||||
<p>통신사·상품·약정을 등록하고 관리합니다.</p>
|
||||
</div>
|
||||
<Space>
|
||||
<Button icon={<TeamOutlined />} onClick={() => setGroupOpen(true)}>
|
||||
그룹관리
|
||||
</Button>
|
||||
<Button type="primary" icon={<PlusSquareOutlined />} onClick={openCreate}>
|
||||
등록
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Card size="small">
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data || []}
|
||||
columns={columns}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '등록된 통신사가 없습니다.' }}
|
||||
scroll={{ x: 960 }}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<IspGroupManageModal open={groupOpen} onClose={() => setGroupOpen(false)} />
|
||||
|
||||
<DraggableModal
|
||||
title={editing ? '통신사 수정' : '통신사 등록'}
|
||||
open={open}
|
||||
onCancel={() => { setOpen(false); setEditing(null) }}
|
||||
onOk={() => form.submit()}
|
||||
confirmLoading={saveMut.isPending}
|
||||
okText={editing ? '수정' : '등록'}
|
||||
destroyOnClose
|
||||
width={420}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={(v) => saveMut.mutate(v)}>
|
||||
<Form.Item name="telecom" label="본사" rules={[{ required: true, message: '본사를 선택하세요.' }]}>
|
||||
<Select options={TELECOMS.map((t) => ({ value: t, label: t }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="통신사명" rules={[{ required: true, message: '통신사명을 입력하세요.' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</DraggableModal>
|
||||
|
||||
<DraggableModal
|
||||
title={`상품관리 — ${productIsp?.name || ''}`}
|
||||
open={!!productIsp}
|
||||
onCancel={() => setProductIsp(null)}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
width={560}
|
||||
centered
|
||||
>
|
||||
<Form
|
||||
form={productForm}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 12 }}
|
||||
onFinish={(v) => addProductMut.mutate(v)}
|
||||
initialValues={{ type: 'e01' }}
|
||||
>
|
||||
<Form.Item name="type" rules={[{ required: true }]}>
|
||||
<Select style={{ width: 110 }} options={PRODUCT_TYPES} />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" rules={[{ required: true, message: '상품명' }]}>
|
||||
<Input placeholder="상품명" style={{ width: 200 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={addProductMut.isPending} className={ROW_BTN}>
|
||||
추가
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={productsQuery.isLoading}
|
||||
dataSource={productsQuery.data || []}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '등록된 상품이 없습니다.' }}
|
||||
columns={[
|
||||
{
|
||||
title: '종류',
|
||||
width: 80,
|
||||
render: (_: unknown, r: IspProduct) => productTypeLabel(r.productType || r.type),
|
||||
},
|
||||
{ title: '상품명', dataIndex: 'name' },
|
||||
{
|
||||
title: '상태',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, r: IspProduct) => (isActive(r) ? '사용' : '중지'),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, r: IspProduct) => (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => {
|
||||
if (window.confirm('삭제하시겠습니까?')) delProductMut.mutate(r.id)
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DraggableModal>
|
||||
|
||||
<DraggableModal
|
||||
title={`약정관리 — ${agreementIsp?.name || ''}`}
|
||||
open={!!agreementIsp}
|
||||
onCancel={() => setAgreementIsp(null)}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
width={480}
|
||||
centered
|
||||
>
|
||||
<Form
|
||||
form={agreementForm}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 12 }}
|
||||
onFinish={(v) => addAgreementMut.mutate(v)}
|
||||
>
|
||||
<Form.Item name="name" rules={[{ required: true, message: '약정명' }]}>
|
||||
<Input placeholder="약정명" style={{ width: 240 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={addAgreementMut.isPending} className={ROW_BTN}>
|
||||
추가
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={agreementsQuery.isLoading}
|
||||
dataSource={agreementsQuery.data || []}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '등록된 약정이 없습니다.' }}
|
||||
columns={[
|
||||
{ title: '약정명', dataIndex: 'name' },
|
||||
{
|
||||
title: '상태',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, r: IspAgreement) => (isActive(r) ? '사용' : '중지'),
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
width: 70,
|
||||
align: 'center' as const,
|
||||
render: (_: unknown, r: IspAgreement) => (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => {
|
||||
if (window.confirm('삭제하시겠습니까?')) delAgreementMut.mutate(r.id)
|
||||
}}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DraggableModal>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -18,7 +18,7 @@ type LevelsData = {
|
||||
const FALLBACK_ROLES = ['총괄', '팀장', '사원', '영업', '딜러', '개발자']
|
||||
|
||||
const FALLBACK_ROWS: Record<string, string[]> = {
|
||||
menu: ['상품관리', '계약관리', '접수관리', '정산관리', '일정관리', '예약관리', '고객관리', '장부관리', '통계관리', '환경설정'],
|
||||
menu: ['상품관리', '계약관리', '접수관리', '영업점관리', '정산관리', '일정관리', '예약관리', '고객관리', '장부관리', '통계관리', '환경설정'],
|
||||
product: ['목록조회', '상품등록', '상품수정', '상품삭제', '정책관리', '엑셀다운로드'],
|
||||
contract: ['목록조회', '계약등록', '계약수정', '상태변경', '엑셀다운로드'],
|
||||
reception: ['목록조회', '접수등록', '접수수정', '진행변경', '엑셀다운로드'],
|
||||
@@ -65,7 +65,7 @@ function buildMenuDefaults(): RoleMatrix {
|
||||
flags[menu] = (() => {
|
||||
if (role === '총괄' || role === '팀장' || role === '개발자') return true
|
||||
if (role === '사원') {
|
||||
return ['계약관리', '접수관리', '정산관리', '일정관리', '예약관리', '고객관리', '장부관리'].includes(menu)
|
||||
return ['계약관리', '접수관리', '영업점관리', '정산관리', '일정관리', '예약관리', '고객관리', '장부관리'].includes(menu)
|
||||
}
|
||||
if (role === '영업' || role === '딜러') {
|
||||
return ['계약관리', '접수관리', '정산관리', '일정관리', '예약관리'].includes(menu)
|
||||
|
||||
@@ -53,6 +53,7 @@ const MENU_EXPOSURE = [
|
||||
{ key: 'product', label: '상품관리' },
|
||||
{ key: 'contract', label: '계약관리' },
|
||||
{ key: 'reception', label: '접수관리' },
|
||||
{ key: 'branch', label: '영업점관리' },
|
||||
{ key: 'invoice', label: '정산관리' },
|
||||
{ key: 'pending', label: '일정관리' },
|
||||
{ key: 'booking', label: '예약관리' },
|
||||
@@ -541,7 +542,7 @@ export default function SettingMemberPage() {
|
||||
loading={saveMut.isPending}
|
||||
icon={<EditOutlined />}
|
||||
>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
|
||||
@@ -268,7 +268,7 @@ export default function SettingPartner1Page() {
|
||||
loading={saveMut.isPending}
|
||||
icon={<EditOutlined />}
|
||||
>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Space>
|
||||
{editing ? (
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
import { useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { Button, Card, Form, Input, Space, Typography, message } from 'antd'
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import DraggableModal from '../../components/DraggableModal'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type QdCenter = {
|
||||
id: number
|
||||
name?: string
|
||||
authorized?: boolean
|
||||
baseUrl?: string | null
|
||||
}
|
||||
|
||||
type QdLog = {
|
||||
id: number
|
||||
createdAt?: string
|
||||
name?: string
|
||||
status?: string
|
||||
memo?: string
|
||||
orderQuickDeliveryClientId?: number
|
||||
}
|
||||
|
||||
type LogPage = {
|
||||
content?: QdLog[]
|
||||
last?: boolean
|
||||
totalElements?: number
|
||||
}
|
||||
|
||||
export default function SettingQuickDeliveryPage() {
|
||||
return (
|
||||
<div className="stock-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<SettingOutlined className="product-title-icon" />
|
||||
퀵전송 연동
|
||||
</h2>
|
||||
<p>입력처(상부점) 인증과 진행알림 로그를 관리합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
<CenterAuthSection />
|
||||
<LogSection />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CenterAuthSection() {
|
||||
const qc = useQueryClient()
|
||||
const [authForm] = Form.useForm()
|
||||
const [target, setTarget] = useState<(QdCenter & { mode: 'auth' | 'reauth' }) | null>(null)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-qd-centers'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<QdCenter[]>>(
|
||||
`${apiPrefix()}/homes/quick-delivery/centers`,
|
||||
)
|
||||
return unwrap(data) || []
|
||||
},
|
||||
})
|
||||
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['homes-qd-centers'] })
|
||||
|
||||
const authMut = useMutation({
|
||||
mutationFn: async (values: { userid: string; password: string; baseUrl?: string }) => {
|
||||
const id = target!.id
|
||||
if (target!.mode === 'reauth') {
|
||||
const { data } = await client.post(
|
||||
`${apiPrefix()}/homes/quick-delivery/center/${id}/reauth`,
|
||||
{ userid: values.userid, password: values.password },
|
||||
)
|
||||
return unwrap(data as ApiResponse)
|
||||
}
|
||||
const { data } = await client.post(
|
||||
`${apiPrefix()}/homes/quick-delivery/center/${id}/auth`,
|
||||
values,
|
||||
)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('완료되었습니다.')
|
||||
setTarget(null)
|
||||
authForm.resetFields()
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
const removeMut = useMutation({
|
||||
mutationFn: async (id: number) => {
|
||||
const { data } = await client.delete(`${apiPrefix()}/homes/quick-delivery/center/${id}/auth`)
|
||||
return unwrap(data as ApiResponse)
|
||||
},
|
||||
onSuccess: () => {
|
||||
message.success('인증을 해제했습니다.')
|
||||
refresh()
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
})
|
||||
|
||||
return (
|
||||
<Card size="small" title="입력처(상부점) 인증관리" style={{ marginBottom: 16 }}>
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={query.data || []}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '등록된 입력처가 없습니다.' }}
|
||||
columns={[
|
||||
{ title: '입력처', dataIndex: 'name' },
|
||||
{
|
||||
title: '인증상태',
|
||||
width: 100,
|
||||
render: (_: unknown, row: QdCenter) =>
|
||||
row.authorized ? (
|
||||
<Typography.Text type="success">인증됨</Typography.Text>
|
||||
) : (
|
||||
<Typography.Text type="secondary">미인증</Typography.Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '상부점 URL',
|
||||
dataIndex: 'baseUrl',
|
||||
ellipsis: true,
|
||||
render: (v?: string | null) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
width: 160,
|
||||
render: (_: unknown, row: QdCenter) => (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
className="branch-row-btn"
|
||||
onClick={() => {
|
||||
setTarget({ ...row, mode: row.authorized ? 'reauth' : 'auth' })
|
||||
authForm.setFieldsValue({
|
||||
baseUrl: row.baseUrl || '',
|
||||
userid: '',
|
||||
password: '',
|
||||
})
|
||||
}}
|
||||
>
|
||||
{row.authorized ? '재인증' : '인증'}
|
||||
</Button>
|
||||
{row.authorized && (
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => {
|
||||
if (window.confirm('인증을 해제하시겠습니까?')) removeMut.mutate(row.id)
|
||||
}}
|
||||
>
|
||||
해제
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<DraggableModal
|
||||
title={`${target?.name || ''} — ${target?.mode === 'reauth' ? '재인증' : '인증'}`}
|
||||
open={!!target}
|
||||
onCancel={() => setTarget(null)}
|
||||
onOk={() => authForm.submit()}
|
||||
confirmLoading={authMut.isPending}
|
||||
destroyOnClose
|
||||
width={440}
|
||||
>
|
||||
<Form form={authForm} layout="vertical" onFinish={(v) => authMut.mutate(v)}>
|
||||
{target?.mode === 'auth' && (
|
||||
<Form.Item
|
||||
name="baseUrl"
|
||||
label="상부점 기본 URL"
|
||||
rules={[{ required: true, message: 'URL을 입력하세요.' }]}
|
||||
>
|
||||
<Input placeholder="https://upper.example.com" />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="userid" label="상부점 아이디" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="상부점 비밀번호" rules={[{ required: true }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</DraggableModal>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function LogSection() {
|
||||
const [page, setPage] = useState(0)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-qd-logs', page],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<LogPage>>(
|
||||
`${apiPrefix()}/homes/quick-delivery/logs`,
|
||||
{ params: { page, size: 50 } },
|
||||
)
|
||||
return unwrap(data) || { content: [], last: true }
|
||||
},
|
||||
})
|
||||
|
||||
const logs = query.data?.content || []
|
||||
|
||||
return (
|
||||
<Card size="small" title="진행알림 로그(상부점 → 하위점)">
|
||||
<CareTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={query.isLoading}
|
||||
dataSource={logs}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '로그가 없습니다.' }}
|
||||
columns={[
|
||||
{
|
||||
title: '일시',
|
||||
dataIndex: 'createdAt',
|
||||
width: 150,
|
||||
render: (v?: string) => (v || '').replace('T', ' ').substring(0, 16),
|
||||
},
|
||||
{ title: '작성자', dataIndex: 'name', width: 100 },
|
||||
{ title: '상태', dataIndex: 'status', width: 100 },
|
||||
{ title: '메모', dataIndex: 'memo', ellipsis: true },
|
||||
{
|
||||
title: '접수',
|
||||
dataIndex: 'orderQuickDeliveryClientId',
|
||||
width: 90,
|
||||
render: (v?: number) => (v != null ? `#${v}` : '-'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Space style={{ marginTop: 12 }}>
|
||||
<Button size="small" disabled={page === 0} onClick={() => setPage((p) => p - 1)}>
|
||||
이전
|
||||
</Button>
|
||||
<Typography.Text type="secondary">페이지 {page + 1}</Typography.Text>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={query.data?.last ?? true}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
다음
|
||||
</Button>
|
||||
</Space>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Card, Typography } from 'antd'
|
||||
import { SettingOutlined } from '@ant-design/icons'
|
||||
import CareTable from '../../components/CareTable'
|
||||
import client, { apiPrefix, unwrap, type ApiResponse } from '../../api/client'
|
||||
|
||||
type Report = {
|
||||
lastLogin?: string | null
|
||||
lastOrderCreated?: string | null
|
||||
dailyPageViews?: { date: string; count: number }[]
|
||||
}
|
||||
|
||||
export default function SettingUsageReportPage() {
|
||||
const query = useQuery({
|
||||
queryKey: ['homes-access-log-report'],
|
||||
queryFn: async () => {
|
||||
const { data } = await client.get<ApiResponse<Report>>(
|
||||
`${apiPrefix()}/homes/access-log/report`,
|
||||
)
|
||||
return unwrap(data)
|
||||
},
|
||||
})
|
||||
|
||||
const fmt = (n: number) => n.toLocaleString('ko-KR')
|
||||
const views = query.data?.dailyPageViews || []
|
||||
|
||||
return (
|
||||
<div className="stock-page">
|
||||
<div className="stock-title">
|
||||
<div>
|
||||
<h2>
|
||||
<SettingOutlined className="product-title-icon" />
|
||||
이용현황
|
||||
</h2>
|
||||
<p>로그인·접수·페이지뷰 이용 현황을 확인합니다.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{query.isLoading && <Typography.Text type="secondary">로딩 중...</Typography.Text>}
|
||||
|
||||
{query.data && (
|
||||
<>
|
||||
<Card size="small" title="종합정보" style={{ maxWidth: 520, marginBottom: 16 }}>
|
||||
<CareTable
|
||||
size="small"
|
||||
pagination={false}
|
||||
showHeader={false}
|
||||
rowKey="k"
|
||||
dataSource={[
|
||||
{ k: 'login', label: '마지막 로그인 일자', value: query.data.lastLogin || '-' },
|
||||
{
|
||||
k: 'order',
|
||||
label: '마지막 접수 등록일자',
|
||||
value: query.data.lastOrderCreated || '-',
|
||||
},
|
||||
]}
|
||||
columns={[
|
||||
{ title: '', dataIndex: 'label', width: 180 },
|
||||
{ title: '', dataIndex: 'value' },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="최근 1주일 페이지뷰" style={{ maxWidth: 520 }}>
|
||||
<CareTable
|
||||
size="small"
|
||||
pagination={false}
|
||||
showHeader={false}
|
||||
rowKey="date"
|
||||
dataSource={views}
|
||||
locale={{ emptyText: '데이터가 없습니다.' }}
|
||||
columns={[
|
||||
{ title: '일자', dataIndex: 'date', width: 180 },
|
||||
{
|
||||
title: '건수',
|
||||
dataIndex: 'count',
|
||||
align: 'right' as const,
|
||||
render: (v: number) => `${fmt(v)} 건`,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
{query.isError && (
|
||||
<Typography.Text type="danger">
|
||||
{(query.error as Error)?.message || '이용현황을 불러오지 못했습니다.'}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -483,7 +483,7 @@ export default function SmsAddressPage() {
|
||||
</Form.Item>
|
||||
<div className="sms-send-footer">
|
||||
<Button type="primary" htmlType="submit" loading={saveGroup.isPending}>
|
||||
{editingGroup ? '수정합니다' : '등록'}
|
||||
{editingGroup ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setGroupOpen(false); setEditingGroup(null) }}>취소</Button>
|
||||
</div>
|
||||
|
||||
@@ -360,7 +360,7 @@ export default function StaffManagePage() {
|
||||
</Form.Item>
|
||||
<div className="sms-send-footer">
|
||||
<Button type="primary" htmlType="submit" loading={saveMut.isPending} icon={<EditOutlined />}>
|
||||
{editing ? '수정합니다' : '등록'}
|
||||
{editing ? '수정' : '등록'}
|
||||
</Button>
|
||||
<Button onClick={() => { setOpen(false); setEditing(null) }}>취소</Button>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/** billing_react 호환 접수/사은품/입력처 상태 코드·라벨 */
|
||||
|
||||
export const PROGRESS_STATUS: Record<string, string> = {
|
||||
f01: '접수', f02: '재접수', f03: '접수진행중', f04: '접수완료',
|
||||
f05: '승인대기중', f06: '회신요청', f07: '접수반송', f08: '미개통오더반송',
|
||||
f09: 'TC실패반송', f10: '접수보류', f11: '접수취소', f12: '개통중',
|
||||
f13: '기사완료', f14: '개통완료', f15: '개통보류', f16: '개통취소',
|
||||
f17: '개통불가', f18: '정산보류', f19: '위면해지', f20: '직권해지',
|
||||
f21: '유보중단환수', f22: '해지', f23: '정산대기', f24: '정산완료',
|
||||
f25: '송금완료', f26: '환수정산완료', f27: '환수완료',
|
||||
f28: 'VOC(귀책)차감', f29: 'VOC(귀책)차감완료',
|
||||
}
|
||||
|
||||
export const GIFT_STATUS: Record<string, string> = {
|
||||
g01: '지급요청', g02: '지급재요청', g03: '반송', g04: '처리중',
|
||||
g05: '지급보류', g06: '지급취소', g07: '지급완료', g08: '회수요청',
|
||||
g09: '회수(반환)완료', g10: '등록완료', g11: '삭제완료', g12: '입금증요청',
|
||||
g13: '입금증완료', g14: '입금증첨부완료', g15: '입금증(센터)전달완료',
|
||||
}
|
||||
|
||||
export const CENTER_STATUS: Record<string, string> = {
|
||||
h01: '정산대기', h02: '정산완료', h03: '입금완료', h04: '정산보류',
|
||||
h05: '위면해지', h06: '직권해지', h07: '유보중단환수', h08: '해지',
|
||||
h09: '해지정산완료', h10: '해지송금완료', h11: 'VOC(귀책)송금', h12: 'VOC(귀책)송금완료',
|
||||
}
|
||||
|
||||
export const GIFT_PLACE: Record<string, string> = {
|
||||
b02: '자체발송', b01: '본사발송', b03: '본사(센터)', b04: '본사(관리자)',
|
||||
}
|
||||
|
||||
export function progressLabel(code?: string | null) {
|
||||
if (!code) return ''
|
||||
return PROGRESS_STATUS[code] || code
|
||||
}
|
||||
|
||||
export function giftLabel(code?: string | null) {
|
||||
if (!code) return ''
|
||||
return GIFT_STATUS[code] || code
|
||||
}
|
||||
|
||||
export function centerLabel(code?: string | null) {
|
||||
if (!code) return ''
|
||||
return CENTER_STATUS[code] || code
|
||||
}
|
||||
|
||||
export function giftPlaceLabel(code?: string | null) {
|
||||
if (!code) return ''
|
||||
if (code === '본사') return '본사발송'
|
||||
if (code === '영업점') return '자체발송'
|
||||
return GIFT_PLACE[code] || code
|
||||
}
|
||||
|
||||
export function statusOptions(map: Record<string, string>, allowed?: string[]) {
|
||||
const entries = Object.entries(map)
|
||||
const filtered = allowed?.length ? entries.filter(([k]) => allowed.includes(k)) : entries
|
||||
return filtered.map(([value, label]) => ({ value, label }))
|
||||
}
|
||||
|
||||
/** 진행현황 배지 색상 */
|
||||
export function progressBadgeClass(status?: string) {
|
||||
const s = (status || '').trim()
|
||||
if (s === 'f01' || s === 'f02' || s === 'f03' || s === 'f04') return 'badge badge-ps-1'
|
||||
if (s === 'f05' || s === 'f06' || s === 'f12' || s === 'f13') return 'badge badge-ps-2'
|
||||
if (s === 'f07' || s === 'f08' || s === 'f09' || s === 'f10' || s === 'f15') return 'badge badge-ps-3'
|
||||
if (s === 'f14') return 'badge badge-ps-4'
|
||||
if (s === 'f23') return 'badge badge-ps-5'
|
||||
if (s === 'f24' || s === 'f25') return 'badge badge-ps-6'
|
||||
if (s === 'f11' || s === 'f16' || s === 'f17' || s === 'f22' || s.startsWith('f1') && Number(s.slice(1)) >= 19) return 'badge badge-ps-9'
|
||||
if (s.startsWith('f19') || s.startsWith('f2')) return 'badge badge-ps-9'
|
||||
// legacy
|
||||
if (s.startsWith('1.') || s.includes('접수완료')) return 'badge badge-ps-1'
|
||||
if (s.startsWith('4.') || s.includes('개통완료')) return 'badge badge-ps-4'
|
||||
if (s.startsWith('6.') || s === '정산완료') return 'badge badge-ps-6'
|
||||
if (s.startsWith('9.') || s.includes('취소')) return 'badge badge-ps-9'
|
||||
return 'badge'
|
||||
}
|
||||
|
||||
export function giftBadgeClass(status?: string) {
|
||||
const s = (status || '').trim()
|
||||
if (!s || s === '미지급') return 'badge badge-gs-none'
|
||||
if (s === 'g01' || s === 'g02' || s === '지급대기' || s === '지급요청') return 'badge badge-gs-wait'
|
||||
if (s === 'g07' || s === '지급완료') return 'badge badge-gs-done'
|
||||
if (s === 'g03' || s === '반송') return 'badge badge-gs-return'
|
||||
if (s === 'g04' || s === 'g05') return 'badge badge-ps-3'
|
||||
return 'badge'
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user