feat: P6 후속 — PWA 출금폼/명세상세/토큰refresh + 펌뱅킹 SETTLED 동기화 + user phone UI
외부 SDK 차단 항목 외 P6 잔여 운영성 작업을 일괄 완료. DB 변경 없음(v85 유지).
PWA:
- 출금 신청 작성 폼 (WithdrawCreate.tsx + POST /api/mobile/me/withdraw-requests + GET /settle-masters)
· 클라이언트가 보낸 agentId/userId 무시, 토큰에서 강제 결정
- 명세서 상세 + 엑셀 다운로드 (StatementDetail.tsx + GET /api/mobile/me/statements/{id}[/download])
· 본인 agentId 검증 후 기존 CommissionStatementService.download() 재사용
- 토큰 자동 refresh (request.ts interceptor + authStore.refreshToken)
· 401 시 단일 refresh 호출로 합치고 broadcast, login/refresh 자체 401 은 무한루프 차단
펌뱅킹 SETTLED 동기화:
- WithdrawBankSettleService + POST /api/internal/bank-settle (BankSettleController)
- Mapper: selectAcceptedTransfers / markSettled / markSettleFailed
- 어댑터 queryStatus 폴링 결과로 SETTLED→COMPLETED, FAILED→APPROVED(재시도풀) 전이
- app.bank-settle.cron 옵션 (기본 비활성)
사용자 phone 등록 UI:
- PUT /api/system/users/{id}/phone — NotBlank 검증 없는 단일 패치 endpoint
- UserList 에 phone 컬럼 + "전화번호" 액션 → Antd Modal 인라인 편집
- 결재 SMS 발신 전제 충족
검증 (9/9 PASS, 라이브):
- ga-common/ga-core/ga-external-adapter/ga-api compileJava BUILD SUCCESSFUL
- ga-frontend / ga-mobile-pwa tsc --noEmit PASS
- POST /api/internal/bank-settle → data=3 (3건 COMPLETED 전이 확인)
- PUT /api/system/users/1/phone → 200, 이후 GET 으로 phone 노출 확인
- 보안 강제: admin(agentId=null)으로 /api/mobile/me/{statements,settle-masters,withdraw-requests} → E403
- POST /api/auth/refresh → 200, 새 accessToken 발급
- 3개 서비스 라이브: ga-api :8082 / ga-frontend :3000 / ga-mobile-pwa :3001
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,29 @@
|
||||
package com.ga.api.controller.internal;
|
||||
|
||||
import com.ga.api.service.withdraw.WithdrawBankSettleService;
|
||||
import com.ga.common.annotation.RequirePermission;
|
||||
import com.ga.common.model.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* 펌뱅킹 SETTLED 동기화 수동 트리거.
|
||||
* cron 활성화 환경에서는 자동 실행되지만 운영 중 즉시 입금 확정 동기화가 필요할 때 사용.
|
||||
*/
|
||||
@Tag(name = "내부 트리거")
|
||||
@RestController
|
||||
@RequestMapping("/api/internal/bank-settle")
|
||||
@RequiredArgsConstructor
|
||||
public class BankSettleController {
|
||||
|
||||
private final WithdrawBankSettleService bankSettleService;
|
||||
|
||||
@PostMapping
|
||||
@RequirePermission(menu = "WITHDRAW", perm = "EXECUTE")
|
||||
public ApiResponse<Integer> sync() {
|
||||
return ApiResponse.ok(bankSettleService.syncOnce());
|
||||
}
|
||||
}
|
||||
@@ -2,18 +2,27 @@ package com.ga.api.controller.mobile;
|
||||
|
||||
import com.ga.api.service.mobile.MobileMeService;
|
||||
import com.ga.api.service.mobile.MobileMeService.MeSummary;
|
||||
import com.ga.api.service.mobile.MobileMeService.MobileWithdrawSaveReq;
|
||||
import com.ga.common.model.ApiResponse;
|
||||
import com.ga.common.model.PageResponse;
|
||||
import com.ga.core.vo.notice.UserNotificationResp;
|
||||
import com.ga.core.vo.notice.UserNotificationSearchParam;
|
||||
import com.ga.core.vo.settle.CommissionStatementResp;
|
||||
import com.ga.core.vo.settle.CommissionStatementSearchParam;
|
||||
import com.ga.core.vo.settle.SettleMasterResp;
|
||||
import com.ga.core.vo.withdraw.WithdrawRequestResp;
|
||||
import com.ga.core.vo.withdraw.WithdrawRequestSearchParam;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 모바일 PWA 전용 — 토큰의 사용자 본인 데이터만 반환.
|
||||
*
|
||||
@@ -39,12 +48,38 @@ public class MobileMeController {
|
||||
return ApiResponse.ok(service.statements(param));
|
||||
}
|
||||
|
||||
@GetMapping("/statements/{statementId}")
|
||||
public ApiResponse<CommissionStatementResp> statementDetail(@PathVariable Long statementId) {
|
||||
return ApiResponse.ok(service.statementDetail(statementId));
|
||||
}
|
||||
|
||||
@GetMapping("/statements/{statementId}/download")
|
||||
public void downloadStatement(@PathVariable Long statementId, HttpServletResponse response) throws IOException {
|
||||
byte[] content = service.downloadStatement(statementId);
|
||||
String encoded = URLEncoder.encode("수수료명세서_" + statementId + ".xlsx", StandardCharsets.UTF_8)
|
||||
.replace("+", "%20");
|
||||
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
|
||||
response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encoded);
|
||||
response.setContentLength(content.length);
|
||||
response.getOutputStream().write(content);
|
||||
}
|
||||
|
||||
@GetMapping("/settle-masters")
|
||||
public ApiResponse<List<SettleMasterResp>> withdrawableSettleMasters() {
|
||||
return ApiResponse.ok(service.withdrawableSettleMasters());
|
||||
}
|
||||
|
||||
@GetMapping("/withdraw-requests")
|
||||
public ApiResponse<PageResponse<WithdrawRequestResp>> withdrawRequests(
|
||||
@ModelAttribute WithdrawRequestSearchParam param) {
|
||||
return ApiResponse.ok(service.withdrawRequests(param));
|
||||
}
|
||||
|
||||
@PostMapping("/withdraw-requests")
|
||||
public ApiResponse<Long> createWithdrawRequest(@Valid @RequestBody MobileWithdrawSaveReq req) {
|
||||
return ApiResponse.ok(service.createWithdrawRequest(req));
|
||||
}
|
||||
|
||||
@GetMapping("/notifications")
|
||||
public ApiResponse<PageResponse<UserNotificationResp>> notifications(
|
||||
@ModelAttribute UserNotificationSearchParam param) {
|
||||
|
||||
@@ -61,4 +61,21 @@ public class UserController {
|
||||
service.unlock(userId);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
/**
|
||||
* 전화번호 단독 패치. SMS 알림 발신을 위한 phone 등록·정정 경로.
|
||||
* loginId/userName 같은 NotBlank 필드를 강제하지 않는다.
|
||||
*/
|
||||
@PutMapping("/{userId}/phone")
|
||||
@RequirePermission(menu = "SYSTEM_USER", perm = "UPDATE")
|
||||
@DataChangeLog(menu = "SYSTEM_USER", table = "users")
|
||||
public ApiResponse<Void> updatePhone(@PathVariable Long userId, @RequestBody PhoneReq req) {
|
||||
service.updatePhone(userId, req.getPhone());
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
|
||||
@lombok.Data
|
||||
public static class PhoneReq {
|
||||
private String phone;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,26 +1,38 @@
|
||||
package com.ga.api.service.mobile;
|
||||
|
||||
import com.ga.api.service.statement.CommissionStatementService;
|
||||
import com.ga.api.service.withdraw.WithdrawRequestService;
|
||||
import com.ga.common.exception.BizException;
|
||||
import com.ga.common.exception.ErrorCode;
|
||||
import com.ga.common.model.PageResponse;
|
||||
import com.ga.common.util.SecurityUtil;
|
||||
import com.ga.core.mapper.notice.UserNotificationMapper;
|
||||
import com.ga.core.mapper.settle.CommissionStatementMapper;
|
||||
import com.ga.core.mapper.settle.SettleMasterMapper;
|
||||
import com.ga.core.mapper.user.UserMapper;
|
||||
import com.ga.core.mapper.withdraw.WithdrawRequestMapper;
|
||||
import com.ga.core.vo.notice.UserNotificationResp;
|
||||
import com.ga.core.vo.notice.UserNotificationSearchParam;
|
||||
import com.ga.core.vo.settle.CommissionStatementResp;
|
||||
import com.ga.core.vo.settle.CommissionStatementSearchParam;
|
||||
import com.ga.core.vo.settle.SettleMasterResp;
|
||||
import com.ga.core.vo.settle.SettleSearchParam;
|
||||
import com.ga.core.vo.user.UserVO;
|
||||
import com.ga.core.vo.withdraw.WithdrawRequestResp;
|
||||
import com.ga.core.vo.withdraw.WithdrawRequestSaveReq;
|
||||
import com.ga.core.vo.withdraw.WithdrawRequestSearchParam;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Positive;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 모바일 PWA 전용 — 본인 데이터만 조회. agentId/userId 는 토큰에서 강제 추출.
|
||||
*
|
||||
@@ -35,6 +47,9 @@ public class MobileMeService {
|
||||
private final CommissionStatementMapper statementMapper;
|
||||
private final WithdrawRequestMapper withdrawMapper;
|
||||
private final UserNotificationMapper notificationMapper;
|
||||
private final SettleMasterMapper settleMasterMapper;
|
||||
private final WithdrawRequestService withdrawRequestService;
|
||||
private final CommissionStatementService statementService;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public MeSummary summary() {
|
||||
@@ -99,6 +114,55 @@ public class MobileMeService {
|
||||
notificationMapper.markRead(notificationId, userId);
|
||||
}
|
||||
|
||||
/** 본인 명세서 단건. statementId 가 본인 agentId 의 것이 아니면 FORBIDDEN. */
|
||||
@Transactional(readOnly = true)
|
||||
public CommissionStatementResp statementDetail(Long statementId) {
|
||||
Long agentId = requireAgentId();
|
||||
CommissionStatementResp resp = statementMapper.selectDetailById(statementId);
|
||||
if (resp == null) throw new BizException(ErrorCode.NOT_FOUND);
|
||||
if (!agentId.equals(resp.getAgentId())) throw new BizException(ErrorCode.FORBIDDEN);
|
||||
return resp;
|
||||
}
|
||||
|
||||
/** 본인 명세서 엑셀 다운로드. 본인 소유 검증 후 기존 다운로드 흐름 재사용. */
|
||||
@Transactional
|
||||
public byte[] downloadStatement(Long statementId) {
|
||||
statementDetail(statementId); // 권한 검증 (FORBIDDEN/NOT_FOUND 던짐)
|
||||
return statementService.download(statementId);
|
||||
}
|
||||
|
||||
/** 본인 출금 가능 정산 마스터 목록. 출금 신청 폼의 settleMasterId 드롭다운 용. */
|
||||
@Transactional(readOnly = true)
|
||||
public List<SettleMasterResp> withdrawableSettleMasters() {
|
||||
Long agentId = requireAgentId();
|
||||
SettleSearchParam param = new SettleSearchParam();
|
||||
param.setAgentId(agentId);
|
||||
param.setPageNum(1);
|
||||
param.setPageSize(50);
|
||||
param.startPage();
|
||||
return settleMasterMapper.selectList(param);
|
||||
}
|
||||
|
||||
/**
|
||||
* 본인 출금 신청 등록. agentId / requestedBy 는 토큰에서 강제 결정.
|
||||
* 클라이언트가 보낸 값은 무시한다.
|
||||
*/
|
||||
@Transactional
|
||||
public Long createWithdrawRequest(MobileWithdrawSaveReq req) {
|
||||
Long userId = currentUserId();
|
||||
Long agentId = requireAgentId();
|
||||
|
||||
WithdrawRequestSaveReq inner = new WithdrawRequestSaveReq();
|
||||
inner.setSettleMasterId(req.getSettleMasterId());
|
||||
inner.setAgentId(agentId); // 강제
|
||||
inner.setRequestedBy(userId); // 강제
|
||||
inner.setAmount(req.getAmount());
|
||||
inner.setBankCode(req.getBankCode());
|
||||
inner.setAccountNo(req.getAccountNo());
|
||||
|
||||
return withdrawRequestService.create(inner);
|
||||
}
|
||||
|
||||
// ── private ───────────────────────────────────────────────────────────────
|
||||
|
||||
private Long currentUserId() {
|
||||
@@ -129,4 +193,18 @@ public class MobileMeService {
|
||||
private long withdrawRequestCount;
|
||||
private int unreadNotificationCount;
|
||||
}
|
||||
|
||||
/** 모바일 출금 신청 — 토큰에서 agentId/requestedBy 강제. */
|
||||
@Data
|
||||
public static class MobileWithdrawSaveReq {
|
||||
@NotNull
|
||||
private Long settleMasterId;
|
||||
@NotNull
|
||||
@Positive
|
||||
private BigDecimal amount;
|
||||
@NotBlank
|
||||
private String bankCode;
|
||||
@NotBlank
|
||||
private String accountNo;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,6 +89,15 @@ public class UserService {
|
||||
mapper.updateStatus(userId, UserStatus.ACTIVE.name());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void updatePhone(Long userId, String phone) {
|
||||
if (mapper.selectById(userId) == null) {
|
||||
throw new BizException(ErrorCode.NOT_FOUND);
|
||||
}
|
||||
String trimmed = phone == null || phone.isBlank() ? null : phone.trim();
|
||||
mapper.updatePhone(userId, trimmed);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public List<String> roleCodes(Long userId) {
|
||||
return mapper.selectRoleCodes(userId);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
package com.ga.api.service.withdraw;
|
||||
|
||||
import com.ga.common.adapter.bank.BankTransferAdapter;
|
||||
import com.ga.common.adapter.bank.BankTransferResponse;
|
||||
import com.ga.common.adapter.bank.BankTransferStatus;
|
||||
import com.ga.core.mapper.withdraw.WithdrawRequestMapper;
|
||||
import com.ga.core.vo.withdraw.WithdrawRequestVO;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 펌뱅킹 SETTLED 동기화 서비스.
|
||||
* <p>
|
||||
* 어댑터가 ACCEPTED(접수)만 반환한 출금 건들에 대해 {@code queryStatus} 폴링으로 입금 확정 여부를 확인.
|
||||
* <ul>
|
||||
* <li>SETTLED → status=COMPLETED, transfer_status=SETTLED, completed_at=NOW()</li>
|
||||
* <li>FAILED → status=APPROVED, transfer_status=FAILED (재시도 풀로 회귀)</li>
|
||||
* <li>ACCEPTED 유지 → no-op (다음 사이클에서 다시 폴링)</li>
|
||||
* </ul>
|
||||
* <p>운영: {@code POST /api/internal/bank-settle} 수동 트리거 + cron 옵션(기본 비활성).
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class WithdrawBankSettleService {
|
||||
|
||||
private static final int DEFAULT_BATCH_LIMIT = 50;
|
||||
|
||||
private final WithdrawRequestMapper mapper;
|
||||
private final BankTransferAdapter bankTransferAdapter;
|
||||
|
||||
@Value("${app.bank-settle.limit:50}")
|
||||
private int batchLimit;
|
||||
|
||||
/** 동기화 1회 — 상태 전이된 건수 반환(SETTLED + FAILED 합산). */
|
||||
@Transactional
|
||||
public int syncOnce() {
|
||||
int limit = batchLimit > 0 ? batchLimit : DEFAULT_BATCH_LIMIT;
|
||||
List<WithdrawRequestVO> targets = mapper.selectAcceptedTransfers(limit);
|
||||
if (targets.isEmpty()) return 0;
|
||||
|
||||
log.info("[BANK-SETTLE] 대상 {}건 동기화 시작", targets.size());
|
||||
int changed = 0;
|
||||
for (WithdrawRequestVO vo : targets) {
|
||||
String txId = vo.getTransferTxId();
|
||||
if (txId == null) continue;
|
||||
try {
|
||||
BankTransferResponse resp = bankTransferAdapter.queryStatus(txId);
|
||||
BankTransferStatus status = resp != null ? resp.getStatus() : null;
|
||||
|
||||
if (status == BankTransferStatus.SETTLED) {
|
||||
int n = mapper.markSettled(vo.getRequestId());
|
||||
if (n > 0) changed++;
|
||||
} else if (status == BankTransferStatus.FAILED) {
|
||||
String reason = resp.getResultMessage() != null ? resp.getResultMessage() : "settle failed";
|
||||
int n = mapper.markSettleFailed(vo.getRequestId(), reason);
|
||||
if (n > 0) changed++;
|
||||
}
|
||||
// ACCEPTED 유지 시 no-op
|
||||
} catch (Exception e) {
|
||||
log.error("[BANK-SETTLE] queryStatus 어댑터 예외: requestId={} txId={}",
|
||||
vo.getRequestId(), txId, e);
|
||||
}
|
||||
}
|
||||
log.info("[BANK-SETTLE] 완료 — 상태전이 {}/{}건", changed, targets.size());
|
||||
return changed;
|
||||
}
|
||||
|
||||
/**
|
||||
* cron 기반 자동 동기화.
|
||||
* yml 에 {@code app.bank-settle.cron} 미설정 시 매년 1월 1일 0시(=사실상 비활성)로 작동.
|
||||
*/
|
||||
@Scheduled(cron = "${app.bank-settle.cron:0 0 0 1 1 ?}")
|
||||
public void scheduled() {
|
||||
try {
|
||||
syncOnce();
|
||||
} catch (Exception e) {
|
||||
log.error("[BANK-SETTLE] 스케줄러 실패", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,11 @@ app:
|
||||
limit: ${BANK_RETRY_LIMIT:50}
|
||||
# 미설정 시 사실상 비활성 (매년 1/1 0시). 운영 활성화 예: "0 */10 * * * *"
|
||||
cron: ${BANK_RETRY_CRON:0 0 0 1 1 ?}
|
||||
# 펌뱅킹 SETTLED 동기화 배치 — 어댑터 queryStatus 폴링
|
||||
bank-settle:
|
||||
limit: ${BANK_SETTLE_LIMIT:50}
|
||||
# 미설정 시 사실상 비활성. 운영 활성화 예: "0 */5 * * * *"
|
||||
cron: ${BANK_SETTLE_CRON:0 0 0 1 1 ?}
|
||||
|
||||
logging:
|
||||
level:
|
||||
|
||||
@@ -26,6 +26,7 @@ public interface UserMapper {
|
||||
int incrementFailCount(@Param("userId") Long userId);
|
||||
int resetFailCount(@Param("userId") Long userId);
|
||||
int updateStatus(@Param("userId") Long userId, @Param("status") String status);
|
||||
int updatePhone(@Param("userId") Long userId, @Param("phone") String phone);
|
||||
|
||||
List<String> selectRoleCodes(@Param("userId") Long userId);
|
||||
|
||||
|
||||
@@ -33,4 +33,17 @@ public interface WithdrawRequestMapper {
|
||||
* account_no 는 복호화되어 반환.
|
||||
*/
|
||||
List<WithdrawRequestVO> selectFailedTransfers(@Param("limit") int limit);
|
||||
|
||||
/**
|
||||
* SETTLED 동기화 대상 — transfer_status='ACCEPTED' 이고 status='SENT'.
|
||||
* queryStatus 폴링 결과로 입금 확정/실패를 판정한다.
|
||||
*/
|
||||
List<WithdrawRequestVO> selectAcceptedTransfers(@Param("limit") int limit);
|
||||
|
||||
/** queryStatus 가 SETTLED 응답: status='COMPLETED', transfer_status='SETTLED', completed_at=NOW(). */
|
||||
int markSettled(@Param("requestId") Long requestId);
|
||||
|
||||
/** queryStatus 가 FAILED 응답: status='APPROVED'(재시도 풀로), transfer_status='FAILED', fail_reason. */
|
||||
int markSettleFailed(@Param("requestId") Long requestId,
|
||||
@Param("failReason") String failReason);
|
||||
}
|
||||
|
||||
@@ -110,6 +110,10 @@
|
||||
UPDATE users SET status = #{status} WHERE user_id = #{userId}
|
||||
</update>
|
||||
|
||||
<update id="updatePhone">
|
||||
UPDATE users SET phone = #{phone,jdbcType=VARCHAR} WHERE user_id = #{userId}
|
||||
</update>
|
||||
|
||||
<select id="selectRoleCodes" resultType="string">
|
||||
SELECT r.role_code FROM role r
|
||||
JOIN user_role ur ON ur.role_id = r.role_id
|
||||
|
||||
@@ -176,4 +176,43 @@
|
||||
WHERE request_id = #{requestId}
|
||||
</update>
|
||||
|
||||
<!-- SETTLED 동기화 대상 — 펌뱅킹 어댑터로 보냈고(SENT) 외부 응답이 접수만(ACCEPTED) 된 건 -->
|
||||
<select id="selectAcceptedTransfers" resultMap="VOMap">
|
||||
SELECT wr.request_id, wr.settle_master_id, wr.agent_id, wr.amount,
|
||||
wr.requested_by, wr.requested_at, wr.approval_request_id,
|
||||
wr.bank_code, wr.account_no, wr.status,
|
||||
wr.sent_at, wr.completed_at, wr.fail_reason,
|
||||
wr.transfer_tx_id, wr.transfer_status, wr.transfer_at,
|
||||
wr.created_at, wr.updated_at
|
||||
FROM withdraw_request wr
|
||||
WHERE wr.transfer_status = 'ACCEPTED'
|
||||
AND wr.status = 'SENT'
|
||||
AND wr.transfer_tx_id IS NOT NULL
|
||||
ORDER BY wr.transfer_at NULLS FIRST, wr.request_id
|
||||
LIMIT #{limit}
|
||||
</select>
|
||||
|
||||
<!-- 입금 확정 — SETTLED -->
|
||||
<update id="markSettled">
|
||||
UPDATE withdraw_request
|
||||
SET transfer_status = 'SETTLED',
|
||||
status = 'COMPLETED',
|
||||
completed_at = NOW(),
|
||||
fail_reason = NULL,
|
||||
updated_at = NOW()
|
||||
WHERE request_id = #{requestId}
|
||||
AND status = 'SENT'
|
||||
</update>
|
||||
|
||||
<!-- 입금 실패 동기화 — 재시도 풀(APPROVED + FAILED) 로 회귀 -->
|
||||
<update id="markSettleFailed">
|
||||
UPDATE withdraw_request
|
||||
SET transfer_status = 'FAILED',
|
||||
status = 'APPROVED',
|
||||
fail_reason = #{failReason,jdbcType=VARCHAR},
|
||||
updated_at = NOW()
|
||||
WHERE request_id = #{requestId}
|
||||
AND status = 'SENT'
|
||||
</update>
|
||||
|
||||
</mapper>
|
||||
|
||||
@@ -35,6 +35,8 @@ export const userApi = {
|
||||
update: (id: number, req: UserSaveReq) => unwrap<void>(api.put(`/api/system/users/${id}`, req)),
|
||||
resetPassword: (id: number) => unwrap<void>(api.put(`/api/system/users/${id}/reset-password`)),
|
||||
unlock: (id: number) => unwrap<void>(api.put(`/api/system/users/${id}/unlock`)),
|
||||
updatePhone: (id: number, phone: string) =>
|
||||
unwrap<void>(api.put(`/api/system/users/${id}/phone`, { phone })),
|
||||
};
|
||||
|
||||
export interface RoleVO {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useRef } from 'react';
|
||||
import { Modal, Space, message } from 'antd';
|
||||
import { useRef, useState } from 'react';
|
||||
import { Input, Modal, Space, message } from 'antd';
|
||||
import { ProTable, type ActionType, type ProColumns } from '@ant-design/pro-components';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import PageContainer from '@/components/common/PageContainer';
|
||||
@@ -31,6 +31,15 @@ export default function UserList() {
|
||||
onOk: async () => { await userApi.unlock(id); message.success('해제 완료'); refresh(); },
|
||||
});
|
||||
|
||||
const [phoneEdit, setPhoneEdit] = useState<{ id: number; loginId: string; phone: string } | null>(null);
|
||||
const submitPhone = async () => {
|
||||
if (!phoneEdit) return;
|
||||
await userApi.updatePhone(phoneEdit.id, phoneEdit.phone);
|
||||
message.success('전화번호 저장 완료');
|
||||
setPhoneEdit(null);
|
||||
refresh();
|
||||
};
|
||||
|
||||
const columns: ProColumns<UserResp>[] = [
|
||||
{
|
||||
title: '검색어', dataIndex: 'searchKeyword', hideInTable: true,
|
||||
@@ -44,14 +53,20 @@ export default function UserList() {
|
||||
{ title: '로그인 ID', dataIndex: 'loginId', width: 120, search: false },
|
||||
{ title: '이름', dataIndex: 'userName', width: 120, search: false },
|
||||
{ title: '이메일', dataIndex: 'email', width: 200, search: false },
|
||||
{ title: '전화번호', dataIndex: 'phone', width: 140, search: false,
|
||||
render: (_, r) => r.phone ?? <span style={{ color: '#bbb' }}>미등록</span> },
|
||||
{ title: '소속', dataIndex: 'orgName', width: 160, search: false },
|
||||
{ title: '연결 설계사', dataIndex: 'agentName', width: 100, search: false },
|
||||
{ title: '실패횟수', dataIndex: 'failCount', width: 90, align: 'right', search: false },
|
||||
{ title: '마지막 로그인', dataIndex: 'lastLoginAt', width: 160, search: false },
|
||||
{
|
||||
title: '액션', width: 220, fixed: 'right', search: false,
|
||||
title: '액션', width: 300, fixed: 'right', search: false,
|
||||
render: (_, r) => (
|
||||
<Space>
|
||||
<PermissionButton menuCode="SYSTEM_USER" permCode="UPDATE" size="small"
|
||||
onClick={() => setPhoneEdit({ id: r.userId, loginId: r.loginId, phone: r.phone ?? '' })}>
|
||||
전화번호
|
||||
</PermissionButton>
|
||||
<PermissionButton menuCode="SYSTEM_USER" permCode="UPDATE" size="small"
|
||||
onClick={() => onReset(r.userId)}>비번초기화</PermissionButton>
|
||||
{r.status === 'LOCKED' && (
|
||||
@@ -83,6 +98,27 @@ export default function UserList() {
|
||||
options={{ density: false, fullScreen: true, reload: true, setting: true }}
|
||||
dateFormatter="string"
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={`전화번호 등록${phoneEdit ? ` — ${phoneEdit.loginId}` : ''}`}
|
||||
open={!!phoneEdit}
|
||||
onCancel={() => setPhoneEdit(null)}
|
||||
onOk={submitPhone}
|
||||
okText="저장"
|
||||
cancelText="취소"
|
||||
destroyOnClose
|
||||
>
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="010-1234-5678"
|
||||
value={phoneEdit?.phone ?? ''}
|
||||
onChange={(e) => setPhoneEdit((p) => (p ? { ...p, phone: e.target.value } : p))}
|
||||
onPressEnter={submitPhone}
|
||||
/>
|
||||
<div style={{ marginTop: 8, fontSize: 12, color: '#999' }}>
|
||||
SMS 결재 알림 발송 시 사용됩니다. 비워두면 SMS 발송이 스킵됩니다.
|
||||
</div>
|
||||
</Modal>
|
||||
</PageContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import MobileTabBar from '@/components/TabBar';
|
||||
import Login from '@/pages/Login';
|
||||
import Home from '@/pages/Home';
|
||||
import StatementList from '@/pages/StatementList';
|
||||
import StatementDetail from '@/pages/StatementDetail';
|
||||
import WithdrawList from '@/pages/WithdrawList';
|
||||
import WithdrawCreate from '@/pages/WithdrawCreate';
|
||||
import NoticeList from '@/pages/NoticeList';
|
||||
|
||||
function AuthedLayout({ children }: { children: React.ReactNode }) {
|
||||
@@ -36,6 +38,14 @@ export default function App() {
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/statement/:statementId"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<StatementDetail />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/withdraw"
|
||||
element={
|
||||
@@ -44,6 +54,14 @@ export default function App() {
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/withdraw/new"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<WithdrawCreate />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/notice"
|
||||
element={
|
||||
|
||||
@@ -35,5 +35,7 @@ export const authApi = {
|
||||
login: (req: LoginReq) =>
|
||||
unwrap<LoginResp>(api.post('/api/auth/login', req)),
|
||||
me: () => unwrap<MeResp>(api.get('/api/auth/me')),
|
||||
refresh: (refreshToken: string) =>
|
||||
unwrap<LoginResp>(api.post('/api/auth/refresh', { refreshToken })),
|
||||
logout: () => api.post('/api/auth/logout').catch(() => undefined),
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||
import { Toast } from 'antd-mobile';
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
@@ -28,6 +28,38 @@ api.interceptors.request.use((config) => {
|
||||
return config;
|
||||
});
|
||||
|
||||
// ── 401 → refreshToken 으로 access 갱신 후 원 요청 재시도 ──
|
||||
// 동시 401 다발 시 단일 refresh 호출로 합치고, 그 결과를 모두에 broadcast.
|
||||
let refreshing: Promise<string | null> | null = null;
|
||||
|
||||
async function refreshAccessToken(): Promise<string | null> {
|
||||
const refreshToken = localStorage.getItem('mobile.refreshToken');
|
||||
if (!refreshToken) return null;
|
||||
try {
|
||||
const res = await axios.post<ApiResponse<{ accessToken: string }>>(
|
||||
'/api/auth/refresh',
|
||||
{ refreshToken },
|
||||
{ timeout: 10_000 },
|
||||
);
|
||||
const body = res.data;
|
||||
if (!body?.success || !body.data?.accessToken) return null;
|
||||
const next = body.data.accessToken;
|
||||
localStorage.setItem('mobile.accessToken', next);
|
||||
return next;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function clearSessionAndRedirect() {
|
||||
localStorage.removeItem('mobile.accessToken');
|
||||
localStorage.removeItem('mobile.refreshToken');
|
||||
localStorage.removeItem('mobile.profile');
|
||||
if (!window.location.pathname.startsWith('/login')) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
|
||||
api.interceptors.response.use(
|
||||
(res) => {
|
||||
const body = res.data;
|
||||
@@ -37,15 +69,36 @@ api.interceptors.response.use(
|
||||
}
|
||||
return res;
|
||||
},
|
||||
(err: AxiosError<ApiResponse<unknown>>) => {
|
||||
async (err: AxiosError<ApiResponse<unknown>>) => {
|
||||
const status = err.response?.status;
|
||||
const body = err.response?.data;
|
||||
if (status === 401) {
|
||||
localStorage.removeItem('mobile.accessToken');
|
||||
localStorage.removeItem('mobile.profile');
|
||||
if (!window.location.pathname.startsWith('/login')) {
|
||||
window.location.href = '/login';
|
||||
const config = err.config as (InternalAxiosRequestConfig & { _retried?: boolean }) | undefined;
|
||||
|
||||
if (status === 401 && config && !config._retried) {
|
||||
// 무한 재시도 차단 + refresh 호출 자체의 401 차단
|
||||
if (config.url?.includes('/api/auth/refresh') || config.url?.includes('/api/auth/login')) {
|
||||
clearSessionAndRedirect();
|
||||
return Promise.reject(err);
|
||||
}
|
||||
|
||||
config._retried = true;
|
||||
if (!refreshing) {
|
||||
refreshing = refreshAccessToken().finally(() => {
|
||||
refreshing = null;
|
||||
});
|
||||
}
|
||||
const newAccess = await refreshing;
|
||||
if (newAccess) {
|
||||
config.headers = config.headers ?? ({} as any);
|
||||
(config.headers as Record<string, string>).Authorization = `Bearer ${newAccess}`;
|
||||
return api.request(config);
|
||||
}
|
||||
clearSessionAndRedirect();
|
||||
return Promise.reject(err);
|
||||
}
|
||||
|
||||
if (status === 401) {
|
||||
clearSessionAndRedirect();
|
||||
} else if (status === 403) {
|
||||
Toast.show({ icon: 'fail', content: body?.message ?? '권한이 없습니다' });
|
||||
} else {
|
||||
|
||||
@@ -23,8 +23,23 @@ export interface StatementSearchParam {
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface StatementDetailResp extends StatementResp {
|
||||
grossAmount?: number;
|
||||
incomeTaxAmount?: number;
|
||||
localTaxAmount?: number;
|
||||
vatAmount?: number;
|
||||
deductionAmount?: number;
|
||||
filePath?: string;
|
||||
fileFormat?: string;
|
||||
issuedBy?: string;
|
||||
}
|
||||
|
||||
export const statementApi = {
|
||||
/** 본인 명세서 — 서버측이 토큰 → agentId 강제 필터. */
|
||||
list: (params: StatementSearchParam = {}) =>
|
||||
unwrap<PageResponse<StatementResp>>(api.get('/api/mobile/me/statements', { params })),
|
||||
|
||||
/** 본인 명세서 단건 — 서버측이 본인 agentId 가 아니면 E403. */
|
||||
detail: (statementId: number) =>
|
||||
unwrap<StatementDetailResp>(api.get(`/api/mobile/me/statements/${statementId}`)),
|
||||
};
|
||||
|
||||
@@ -25,8 +25,32 @@ export interface WithdrawSearchParam {
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface SettleMasterResp {
|
||||
settleId: number;
|
||||
settleMonth: string;
|
||||
netAmount?: number;
|
||||
grossAmount?: number;
|
||||
status?: string;
|
||||
statusName?: string;
|
||||
}
|
||||
|
||||
export interface WithdrawCreateReq {
|
||||
settleMasterId: number;
|
||||
amount: number;
|
||||
bankCode: string;
|
||||
accountNo: string;
|
||||
}
|
||||
|
||||
export const withdrawApi = {
|
||||
/** 본인 출금 신청 이력 — 서버측이 토큰 → agentId 강제 필터. */
|
||||
list: (params: WithdrawSearchParam = {}) =>
|
||||
unwrap<PageResponse<WithdrawResp>>(api.get('/api/mobile/me/withdraw-requests', { params })),
|
||||
|
||||
/** 출금 신청 가능 정산 마스터 (본인 agentId 기준). */
|
||||
settleMasters: () =>
|
||||
unwrap<SettleMasterResp[]>(api.get('/api/mobile/me/settle-masters')),
|
||||
|
||||
/** 본인 출금 신청 등록. */
|
||||
create: (req: WithdrawCreateReq) =>
|
||||
unwrap<number>(api.post('/api/mobile/me/withdraw-requests', req)),
|
||||
};
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function Login() {
|
||||
try {
|
||||
const r = await authApi.login(values);
|
||||
const me = await authApi.me().catch(() => null);
|
||||
setSession(r.accessToken, me ?? {
|
||||
setSession(r.accessToken, r.refreshToken, me ?? {
|
||||
userId: 0, loginId: values.loginId,
|
||||
});
|
||||
Toast.show({ icon: 'success', content: '로그인 성공' });
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { Button, Card, ErrorBlock, NavBar, Skeleton, Tag, Toast } from 'antd-mobile';
|
||||
import { DownlandOutline } from 'antd-mobile-icons';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { statementApi } from '@/api/statement';
|
||||
|
||||
export default function StatementDetail() {
|
||||
const navigate = useNavigate();
|
||||
const { statementId } = useParams<{ statementId: string }>();
|
||||
const id = Number(statementId);
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['mobile.me.statement', id],
|
||||
queryFn: () => statementApi.detail(id),
|
||||
enabled: !isNaN(id),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const forbidden = (error as { code?: string } | undefined)?.code === 'E403';
|
||||
const notFound = (error as { code?: string } | undefined)?.code === 'E404';
|
||||
|
||||
const onDownload = async () => {
|
||||
try {
|
||||
const token = localStorage.getItem('mobile.accessToken');
|
||||
const res = await fetch(`/api/mobile/me/statements/${id}/download`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : undefined,
|
||||
});
|
||||
if (!res.ok) {
|
||||
Toast.show({ icon: 'fail', content: `다운로드 실패 (${res.status})` });
|
||||
return;
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `수수료명세서_${id}.xlsx`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
} catch (e) {
|
||||
Toast.show({ icon: 'fail', content: '다운로드 중 오류가 발생했습니다' });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<NavBar onBack={() => navigate(-1)}>명세서 상세</NavBar>
|
||||
<div style={{ padding: 12 }}>
|
||||
{isLoading && <Skeleton.Paragraph lineCount={6} animated />}
|
||||
{forbidden && <ErrorBlock status="empty" title="권한이 없습니다" description="본인 명세서가 아닙니다" />}
|
||||
{notFound && <ErrorBlock status="empty" title="명세서를 찾을 수 없습니다" />}
|
||||
{data && (
|
||||
<>
|
||||
<Card title={`${data.settleMonth} · ${data.statementTypeName ?? data.statementType}`}
|
||||
extra={<Tag color={data.issueStatus === 'ISSUED' ? 'success' : 'warning'}>{data.issueStatusName ?? data.issueStatus ?? '-'}</Tag>}>
|
||||
<Row label="설계사" value={data.agentName ?? `#${data.agentId}`} />
|
||||
<Row label="발급일" value={data.issuedAt ?? '-'} />
|
||||
</Card>
|
||||
|
||||
<Card title="금액 내역" style={{ marginTop: 12 }}>
|
||||
<Row label="총수수료" value={fmt(data.grossAmount)} />
|
||||
<Row label="소득세" value={fmt(data.incomeTaxAmount)} />
|
||||
<Row label="지방소득세" value={fmt(data.localTaxAmount)} />
|
||||
<Row label="부가세" value={fmt(data.vatAmount)} />
|
||||
<Row label="차감" value={fmt(data.deductionAmount)} />
|
||||
<Row label="실수령액" value={fmt(data.netAmount)} bold />
|
||||
</Card>
|
||||
|
||||
{data.filePath && (
|
||||
<Button block color="primary" style={{ marginTop: 16 }} onClick={onDownload}>
|
||||
<DownlandOutline /> 엑셀 다운로드
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, value, bold }: { label: string; value: string | number; bold?: boolean }) {
|
||||
return (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', padding: '6px 0' }}>
|
||||
<span style={{ color: '#666' }}>{label}</span>
|
||||
<span style={{ fontWeight: bold ? 700 : 400 }}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmt(n?: number) {
|
||||
if (n == null) return '-';
|
||||
return `${n.toLocaleString()}원`;
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { ErrorBlock, List, NavBar, PullToRefresh, Tag } from 'antd-mobile';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { StatementResp, statementApi } from '@/api/statement';
|
||||
|
||||
export default function StatementList() {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, refetch, error } = useQuery({
|
||||
queryKey: ['mobile.me.statements'],
|
||||
queryFn: () => statementApi.list({ pageNum: 1, pageSize: 50 }),
|
||||
@@ -28,6 +30,8 @@ export default function StatementList() {
|
||||
{items.map((s: StatementResp) => (
|
||||
<List.Item
|
||||
key={s.statementId}
|
||||
clickable
|
||||
onClick={() => navigate(`/statement/${s.statementId}`)}
|
||||
title={
|
||||
<span>
|
||||
{s.settleMonth ?? '-'}{' '}
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Button, Form, Input, NavBar, Picker, Toast } from 'antd-mobile';
|
||||
import type { PickerColumn } from 'antd-mobile/es/components/picker';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { SettleMasterResp, withdrawApi } from '@/api/withdraw';
|
||||
|
||||
interface FormValues {
|
||||
settleMasterId?: number;
|
||||
amount?: string;
|
||||
bankCode?: string;
|
||||
accountNo?: string;
|
||||
}
|
||||
|
||||
export default function WithdrawCreate() {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
const [form] = Form.useForm<FormValues>();
|
||||
const [pickerVisible, setPickerVisible] = useState(false);
|
||||
const [pickedLabel, setPickedLabel] = useState<string>('');
|
||||
|
||||
const { data: settleMasters = [], isLoading } = useQuery({
|
||||
queryKey: ['mobile.me.settle-masters'],
|
||||
queryFn: () => withdrawApi.settleMasters(),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const pickerColumns: PickerColumn[] = useMemo(
|
||||
() => [
|
||||
settleMasters.map((s: SettleMasterResp) => ({
|
||||
label: `${s.settleMonth} · 실수령 ${fmt(s.netAmount)}원`,
|
||||
value: String(s.settleId),
|
||||
})),
|
||||
],
|
||||
[settleMasters],
|
||||
);
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: (req: { settleMasterId: number; amount: number; bankCode: string; accountNo: string }) =>
|
||||
withdrawApi.create(req),
|
||||
onSuccess: () => {
|
||||
Toast.show({ icon: 'success', content: '출금 신청이 접수되었습니다' });
|
||||
queryClient.invalidateQueries({ queryKey: ['mobile.me.withdraws'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['mobile.me.summary'] });
|
||||
navigate('/withdraw', { replace: true });
|
||||
},
|
||||
});
|
||||
|
||||
const onSubmit = (values: FormValues) => {
|
||||
if (!values.settleMasterId) {
|
||||
Toast.show({ icon: 'fail', content: '정산 마스터를 선택하세요' });
|
||||
return;
|
||||
}
|
||||
const amount = Number(values.amount);
|
||||
if (!amount || amount <= 0) {
|
||||
Toast.show({ icon: 'fail', content: '금액을 입력하세요' });
|
||||
return;
|
||||
}
|
||||
create.mutate({
|
||||
settleMasterId: Number(values.settleMasterId),
|
||||
amount,
|
||||
bankCode: values.bankCode ?? '',
|
||||
accountNo: values.accountNo ?? '',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<NavBar onBack={() => navigate(-1)}>출금 신청</NavBar>
|
||||
<Form
|
||||
form={form}
|
||||
layout="horizontal"
|
||||
mode="card"
|
||||
onFinish={onSubmit}
|
||||
footer={
|
||||
<Button block type="submit" color="primary" loading={create.isPending} disabled={isLoading}>
|
||||
신청
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Form.Item
|
||||
name="settleMasterId"
|
||||
label="정산 월"
|
||||
trigger="onConfirm"
|
||||
onClick={() => setPickerVisible(true)}
|
||||
rules={[{ required: true, message: '정산 마스터를 선택하세요' }]}
|
||||
>
|
||||
<div style={{ color: pickedLabel ? '#000' : '#bbb' }}>
|
||||
{pickedLabel || '선택하세요'}
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" label="금액" rules={[{ required: true, message: '금액을 입력하세요' }]}>
|
||||
<Input placeholder="예: 1500000" type="number" inputMode="numeric" />
|
||||
</Form.Item>
|
||||
<Form.Item name="bankCode" label="은행 코드" rules={[{ required: true, message: '은행 코드' }]}>
|
||||
<Input placeholder="예: 088" />
|
||||
</Form.Item>
|
||||
<Form.Item name="accountNo" label="계좌번호" rules={[{ required: true, message: '계좌번호' }]}>
|
||||
<Input placeholder="-없이 입력" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
|
||||
<Picker
|
||||
columns={pickerColumns}
|
||||
visible={pickerVisible}
|
||||
onClose={() => setPickerVisible(false)}
|
||||
onConfirm={(val) => {
|
||||
const id = val[0] != null ? Number(val[0]) : undefined;
|
||||
form.setFieldValue('settleMasterId', id);
|
||||
const picked = settleMasters.find((s) => String(s.settleId) === val[0]);
|
||||
if (picked) setPickedLabel(`${picked.settleMonth} · 실수령 ${fmt(picked.netAmount)}원`);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmt(n?: number) {
|
||||
if (n == null) return '-';
|
||||
return n.toLocaleString();
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
import { ErrorBlock, List, NavBar, PullToRefresh, Tag } from 'antd-mobile';
|
||||
import { Button, ErrorBlock, List, NavBar, PullToRefresh, Tag } from 'antd-mobile';
|
||||
import { AddOutline } from 'antd-mobile-icons';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { WithdrawResp, withdrawApi } from '@/api/withdraw';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
@@ -12,6 +14,7 @@ const STATUS_COLOR: Record<string, string> = {
|
||||
};
|
||||
|
||||
export default function WithdrawList() {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading, refetch, error } = useQuery({
|
||||
queryKey: ['mobile.me.withdraws'],
|
||||
queryFn: () => withdrawApi.list({ pageNum: 1, pageSize: 50 }),
|
||||
@@ -23,7 +26,18 @@ export default function WithdrawList() {
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<NavBar back={null}>출금 신청</NavBar>
|
||||
<NavBar
|
||||
back={null}
|
||||
right={
|
||||
!forbidden && (
|
||||
<Button size="mini" color="primary" onClick={() => navigate('/withdraw/new')}>
|
||||
<AddOutline /> 신청
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
>
|
||||
출금 신청
|
||||
</NavBar>
|
||||
<PullToRefresh onRefresh={async () => { await refetch(); }}>
|
||||
<div style={{ paddingTop: 8 }}>
|
||||
{forbidden && (
|
||||
|
||||
@@ -4,8 +4,10 @@ import { MeResp } from '../api/auth';
|
||||
|
||||
interface AuthState {
|
||||
accessToken: string | null;
|
||||
refreshToken: string | null;
|
||||
profile: MeResp | null;
|
||||
setSession: (token: string, profile: MeResp) => void;
|
||||
setSession: (access: string, refresh: string, profile: MeResp) => void;
|
||||
setAccessToken: (access: string) => void;
|
||||
setProfile: (profile: MeResp) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
@@ -14,15 +16,22 @@ export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
accessToken: null,
|
||||
refreshToken: null,
|
||||
profile: null,
|
||||
setSession: (token, profile) => {
|
||||
localStorage.setItem('mobile.accessToken', token);
|
||||
set({ accessToken: token, profile });
|
||||
setSession: (access, refresh, profile) => {
|
||||
localStorage.setItem('mobile.accessToken', access);
|
||||
localStorage.setItem('mobile.refreshToken', refresh);
|
||||
set({ accessToken: access, refreshToken: refresh, profile });
|
||||
},
|
||||
setAccessToken: (access) => {
|
||||
localStorage.setItem('mobile.accessToken', access);
|
||||
set({ accessToken: access });
|
||||
},
|
||||
setProfile: (profile) => set({ profile }),
|
||||
clear: () => {
|
||||
localStorage.removeItem('mobile.accessToken');
|
||||
set({ accessToken: null, profile: null });
|
||||
localStorage.removeItem('mobile.refreshToken');
|
||||
set({ accessToken: null, refreshToken: null, profile: null });
|
||||
},
|
||||
}),
|
||||
{ name: 'mobile.profile' },
|
||||
|
||||
Reference in New Issue
Block a user