diff --git a/ga-api/src/main/java/com/ga/api/controller/mobile/MobileMeController.java b/ga-api/src/main/java/com/ga/api/controller/mobile/MobileMeController.java index 27343e9..82ae14e 100644 --- a/ga-api/src/main/java/com/ga/api/controller/mobile/MobileMeController.java +++ b/ga-api/src/main/java/com/ga/api/controller/mobile/MobileMeController.java @@ -80,6 +80,23 @@ public class MobileMeController { return ApiResponse.ok(service.createWithdrawRequest(req)); } + @PostMapping("/withdraw-requests/{requestId}/cancel") + public ApiResponse cancelWithdrawRequest(@PathVariable Long requestId) { + service.cancelMyWithdrawRequest(requestId); + return ApiResponse.ok(); + } + + @PutMapping("/profile/phone") + public ApiResponse updateMyPhone(@RequestBody PhoneReq req) { + service.updateMyPhone(req.getPhone()); + return ApiResponse.ok(); + } + + @lombok.Data + public static class PhoneReq { + private String phone; + } + @GetMapping("/notifications") public ApiResponse> notifications( @ModelAttribute UserNotificationSearchParam param) { diff --git a/ga-api/src/main/java/com/ga/api/service/mobile/MobileMeService.java b/ga-api/src/main/java/com/ga/api/service/mobile/MobileMeService.java index d473ad7..4312e54 100644 --- a/ga-api/src/main/java/com/ga/api/service/mobile/MobileMeService.java +++ b/ga-api/src/main/java/com/ga/api/service/mobile/MobileMeService.java @@ -131,6 +131,14 @@ public class MobileMeService { return statementService.download(statementId); } + /** 본인 phone 자기 수정 — 관리자 거치지 않고 본인이 직접 등록·정정. */ + @Transactional + public void updateMyPhone(String phone) { + Long userId = currentUserId(); + String trimmed = phone == null || phone.isBlank() ? null : phone.trim(); + userMapper.updatePhone(userId, trimmed); + } + /** 본인 출금 가능 정산 마스터 목록. 출금 신청 폼의 settleMasterId 드롭다운 용. */ @Transactional(readOnly = true) public List withdrawableSettleMasters() { @@ -143,6 +151,16 @@ public class MobileMeService { return settleMasterMapper.selectList(param); } + /** 본인 출금 신청 취소. 본인 소유 검증 후 기존 WithdrawRequestService.cancel 호출. */ + @Transactional + public void cancelMyWithdrawRequest(Long requestId) { + Long agentId = requireAgentId(); + com.ga.core.vo.withdraw.WithdrawRequestResp existing = withdrawMapper.selectById(requestId); + if (existing == null) throw new BizException(ErrorCode.NOT_FOUND); + if (!agentId.equals(existing.getAgentId())) throw new BizException(ErrorCode.FORBIDDEN); + withdrawRequestService.cancel(requestId); + } + /** * 본인 출금 신청 등록. agentId / requestedBy 는 토큰에서 강제 결정. * 클라이언트가 보낸 값은 무시한다. diff --git a/ga-api/src/main/java/com/ga/api/service/withdraw/WithdrawBankSettleService.java b/ga-api/src/main/java/com/ga/api/service/withdraw/WithdrawBankSettleService.java index 5eb1428..e9e4851 100644 --- a/ga-api/src/main/java/com/ga/api/service/withdraw/WithdrawBankSettleService.java +++ b/ga-api/src/main/java/com/ga/api/service/withdraw/WithdrawBankSettleService.java @@ -3,7 +3,10 @@ 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.notice.UserNotificationMapper; import com.ga.core.mapper.withdraw.WithdrawRequestMapper; +import com.ga.core.vo.notice.NotificationRefType; +import com.ga.core.vo.notice.UserNotificationVO; import com.ga.core.vo.withdraw.WithdrawRequestVO; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -34,6 +37,7 @@ public class WithdrawBankSettleService { private final WithdrawRequestMapper mapper; private final BankTransferAdapter bankTransferAdapter; + private final UserNotificationMapper notificationMapper; @Value("${app.bank-settle.limit:50}") private int batchLimit; @@ -56,11 +60,17 @@ public class WithdrawBankSettleService { if (status == BankTransferStatus.SETTLED) { int n = mapper.markSettled(vo.getRequestId()); - if (n > 0) changed++; + if (n > 0) { + changed++; + notify(vo, true, null); + } } 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++; + if (n > 0) { + changed++; + notify(vo, false, reason); + } } // ACCEPTED 유지 시 no-op } catch (Exception e) { @@ -72,6 +82,34 @@ public class WithdrawBankSettleService { return changed; } + /** + * 신청자에게 결과 알림. 본인 알림이 실패해도 동기화 흐름은 비차단. + */ + private void notify(WithdrawRequestVO vo, boolean settled, String reason) { + Long userId = vo.getRequestedBy(); + if (userId == null) return; + try { + UserNotificationVO n = new UserNotificationVO(); + n.setUserId(userId); + n.setRefType(NotificationRefType.WITHDRAW.name()); + n.setRefId(vo.getRequestId()); + n.setIsRead(false); + if (settled) { + n.setTitle("출금 입금 완료"); + n.setContent("#" + vo.getRequestId() + " 출금 신청이 입금 완료되었습니다 (" + + vo.getAmount() + "원)."); + } else { + n.setTitle("출금 입금 실패"); + n.setContent("#" + vo.getRequestId() + " 출금이 실패하였습니다" + + (reason != null ? " — " + reason : "") + + ". 재시도 처리됩니다."); + } + notificationMapper.insert(n); + } catch (Exception e) { + log.warn("[BANK-SETTLE] 알림 발행 실패 requestId={}: {}", vo.getRequestId(), e.getMessage()); + } + } + /** * cron 기반 자동 동기화. * yml 에 {@code app.bank-settle.cron} 미설정 시 매년 1월 1일 0시(=사실상 비활성)로 작동. diff --git a/ga-mobile-pwa/src/App.tsx b/ga-mobile-pwa/src/App.tsx index 55121ac..18f4693 100644 --- a/ga-mobile-pwa/src/App.tsx +++ b/ga-mobile-pwa/src/App.tsx @@ -8,6 +8,7 @@ import StatementDetail from '@/pages/StatementDetail'; import WithdrawList from '@/pages/WithdrawList'; import WithdrawCreate from '@/pages/WithdrawCreate'; import NoticeList from '@/pages/NoticeList'; +import Profile from '@/pages/Profile'; function AuthedLayout({ children }: { children: React.ReactNode }) { return ( @@ -70,6 +71,14 @@ export default function App() { } /> + + + + } + /> ); } diff --git a/ga-mobile-pwa/src/api/auth.ts b/ga-mobile-pwa/src/api/auth.ts index 9697dd8..6d9679c 100644 --- a/ga-mobile-pwa/src/api/auth.ts +++ b/ga-mobile-pwa/src/api/auth.ts @@ -37,5 +37,9 @@ export const authApi = { me: () => unwrap(api.get('/api/auth/me')), refresh: (refreshToken: string) => unwrap(api.post('/api/auth/refresh', { refreshToken })), + changePassword: (oldPassword: string, newPassword: string) => + unwrap(api.post('/api/auth/password', { oldPassword, newPassword })), + updateMyPhone: (phone: string) => + unwrap(api.put('/api/mobile/me/profile/phone', { phone })), logout: () => api.post('/api/auth/logout').catch(() => undefined), }; diff --git a/ga-mobile-pwa/src/api/withdraw.ts b/ga-mobile-pwa/src/api/withdraw.ts index b387357..e48b622 100644 --- a/ga-mobile-pwa/src/api/withdraw.ts +++ b/ga-mobile-pwa/src/api/withdraw.ts @@ -53,4 +53,8 @@ export const withdrawApi = { /** 본인 출금 신청 등록. */ create: (req: WithdrawCreateReq) => unwrap(api.post('/api/mobile/me/withdraw-requests', req)), + + /** 본인 출금 신청 취소 (REQUESTED 상태만). */ + cancel: (requestId: number) => + unwrap(api.post(`/api/mobile/me/withdraw-requests/${requestId}/cancel`)), }; diff --git a/ga-mobile-pwa/src/components/TabBar.tsx b/ga-mobile-pwa/src/components/TabBar.tsx index 82035a7..90f772b 100644 --- a/ga-mobile-pwa/src/components/TabBar.tsx +++ b/ga-mobile-pwa/src/components/TabBar.tsx @@ -1,5 +1,5 @@ import { TabBar } from 'antd-mobile'; -import { AppOutline, FileOutline, PayCircleOutline, SoundOutline } from 'antd-mobile-icons'; +import { AppOutline, FileOutline, PayCircleOutline, SoundOutline, UserOutline } from 'antd-mobile-icons'; import { useLocation, useNavigate } from 'react-router-dom'; const tabs = [ @@ -7,6 +7,7 @@ const tabs = [ { key: '/statement', title: '명세서', icon: }, { key: '/withdraw', title: '출금', icon: }, { key: '/notice', title: '공지', icon: }, + { key: '/profile', title: '내정보', icon: }, ]; export default function MobileTabBar() { diff --git a/ga-mobile-pwa/src/pages/Profile.tsx b/ga-mobile-pwa/src/pages/Profile.tsx new file mode 100644 index 0000000..ca1bec9 --- /dev/null +++ b/ga-mobile-pwa/src/pages/Profile.tsx @@ -0,0 +1,139 @@ +import { Button, Card, Dialog, Form, Input, List, NavBar, Toast } from 'antd-mobile'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useNavigate } from 'react-router-dom'; +import { useState } from 'react'; +import { authApi } from '@/api/auth'; +import { useAuthStore } from '@/stores/authStore'; + +export default function Profile() { + const navigate = useNavigate(); + const queryClient = useQueryClient(); + const clear = useAuthStore((s) => s.clear); + const setProfile = useAuthStore((s) => s.setProfile); + const [phoneOpen, setPhoneOpen] = useState(false); + const [phoneInput, setPhoneInput] = useState(''); + const [pwOpen, setPwOpen] = useState(false); + const [pwForm] = Form.useForm<{ oldPassword: string; newPassword: string; confirm: string }>(); + + const { data: me, refetch } = useQuery({ + queryKey: ['mobile.me.profile'], + queryFn: () => authApi.me(), + }); + + const submitPhone = async () => { + try { + await authApi.updateMyPhone(phoneInput.trim()); + Toast.show({ icon: 'success', content: '전화번호 저장 완료' }); + setPhoneOpen(false); + await refetch(); + const fresh = await authApi.me().catch(() => null); + if (fresh) setProfile(fresh); + } catch { + // interceptor 처리 + } + }; + + const submitPassword = async () => { + try { + const v = await pwForm.validateFields(); + if (v.newPassword !== v.confirm) { + Toast.show({ icon: 'fail', content: '새 비밀번호 확인이 일치하지 않습니다' }); + return; + } + await authApi.changePassword(v.oldPassword, v.newPassword); + Toast.show({ icon: 'success', content: '비밀번호 변경 완료' }); + setPwOpen(false); + pwForm.resetFields(); + } catch { + // 검증 실패 또는 서버 오류 + } + }; + + const onLogout = () => { + Dialog.confirm({ + content: '로그아웃 하시겠습니까?', + onConfirm: async () => { + await authApi.logout(); + queryClient.clear(); + clear(); + navigate('/login', { replace: true }); + }, + }); + }; + + return ( +
+ 내 정보 +
+ + + 아이디 + 이름 + 이메일 + 미등록} + clickable + onClick={() => { setPhoneInput(me?.phone ?? ''); setPhoneOpen(true); }} + > + 전화번호 + + 미연결}>설계사 + 소속 + + + + + + setPwOpen(true)}>비밀번호 변경 + 로그아웃 + + + + + +
+ SMS 알림 발송에 사용됩니다. +
+
+ } + actions={[ + [ + { key: 'cancel', text: '취소', onClick: () => setPhoneOpen(false) }, + { key: 'save', text: '저장', bold: true, onClick: submitPhone }, + ], + ]} + /> + + + + + + + + + + + + + } + actions={[ + [ + { key: 'cancel', text: '취소', onClick: () => { setPwOpen(false); pwForm.resetFields(); } }, + { key: 'save', text: '변경', bold: true, onClick: submitPassword }, + ], + ]} + /> + + +
+ + ); +} diff --git a/ga-mobile-pwa/src/pages/WithdrawList.tsx b/ga-mobile-pwa/src/pages/WithdrawList.tsx index 48c31d0..4601a7e 100644 --- a/ga-mobile-pwa/src/pages/WithdrawList.tsx +++ b/ga-mobile-pwa/src/pages/WithdrawList.tsx @@ -1,4 +1,4 @@ -import { Button, ErrorBlock, List, NavBar, PullToRefresh, Tag } from 'antd-mobile'; +import { Button, Dialog, ErrorBlock, List, NavBar, PullToRefresh, SwipeAction, Tag, Toast } from 'antd-mobile'; import { AddOutline } from 'antd-mobile-icons'; import { useQuery } from '@tanstack/react-query'; import { useNavigate } from 'react-router-dom'; @@ -48,19 +48,40 @@ export default function WithdrawList() { )} {items.length > 0 && ( - {items.map((w: WithdrawResp) => ( - #{w.requestId} {w.settleMonth ?? ''}} - description={ - - {w.bankCode ?? '-'} · {w.accountNo ?? '-'} · {w.amount.toLocaleString()}원 - {w.transferTxId && <> · txId={w.transferTxId}} - - } - extra={{w.statusName ?? w.status}} - /> - ))} + {items.map((w: WithdrawResp) => { + const cancellable = w.status === 'REQUESTED'; + const row = ( + #{w.requestId} {w.settleMonth ?? ''}} + description={ + + {w.bankCode ?? '-'} · {w.accountNo ?? '-'} · {w.amount.toLocaleString()}원 + {w.transferTxId && <> · txId={w.transferTxId}} + + } + extra={{w.statusName ?? w.status}} + /> + ); + if (!cancellable) return row; + return ( + { + const ok = await Dialog.confirm({ content: `#${w.requestId} 출금 신청을 취소하시겠습니까?` }); + if (!ok) return; + await withdrawApi.cancel(w.requestId); + Toast.show({ icon: 'success', content: '취소되었습니다' }); + await refetch(); + }, + }]} + > + {row} + + ); + })} )}