feat: P6 모바일 PWA 보안 강화 — /api/mobile/me/* 서버측 본인 필터
스캐폴드의 클라이언트 agentId 쿼리 필터(변조 가능)를
서버측 강제 필터로 교체. 토큰 → user → agentId 자동 추출.
백엔드 (ga-api):
- MobileMeService — 클라이언트 param 의 agentId/userId 는 무시·덮어쓰기.
agentId 미연결 시 BizException(FORBIDDEN). PageHelper 로 정확한 total 산출.
- MobileMeController — GET /api/mobile/me/{summary,statements,
withdraw-requests,notifications} + POST /notifications/{id}/read.
별도 메뉴 권한 없이 인증만 통과 (PWA 사용자 전체 대상).
ga-mobile-pwa:
- api/statement.ts, api/withdraw.ts → /api/mobile/me/* 호출, agentId 제거
- api/notification.ts 신규 — list/markRead/summary
- Home.tsx — summary() 단일 호출로 3카운트 + 안 읽음 Badge
- NoticeList.tsx — Tabs(내 알림 / 공지). 알림 클릭 markRead + summary 무효화
- Statement/WithdrawList — E403(설계사 미연결) 시 ErrorBlock 안내
라이브 검증:
- ga-api 컴파일 BUILD SUCCESSFUL · 재기동 OK
- PWA tsc PASS · vite build PASS (precache 538KB, gzip 176KB)
- admin(agentId=null): /summary → 200 OK 0/0/0,
/statements → E403 차단 정상, /notifications → 200 OK total=0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+27
-2
@@ -339,14 +339,39 @@ npm run preview # 3001 에서 production 번들 서빙
|
||||
- API 응답 확인: `/api/statements` total=112, `/api/withdraw-requests` total=3, `/api/notices/active` 0건(정상), `/api/auth/me` 정상
|
||||
|
||||
### 잔여 (스캐폴드 이후)
|
||||
- 백엔드 `/api/mobile/me/*` 전용 endpoint (서버측 본인 필터 강제)
|
||||
- ~~백엔드 `/api/mobile/me/*` 전용 endpoint~~ → §3-14 에서 완료
|
||||
- ~~본인 알림(`user_notification`) 표시~~ → §3-14 에서 완료
|
||||
- 출금 신청 작성 폼 (현재 이력 조회만)
|
||||
- 정산 명세서 상세 + PDF 다운로드
|
||||
- 본인 알림(`user_notification`) 표시 + 토큰 만료 자동 refresh
|
||||
- 토큰 만료 자동 refresh
|
||||
- 푸시 알림 (FCM 등) — 외부 채널 결정 후
|
||||
- 실 아이콘 PNG (192/512/512 maskable)
|
||||
- 번들 코드 스플리팅 (manualChunks)
|
||||
|
||||
## 3-14. P6 — 모바일 PWA 보안 강화: `/api/mobile/me/*` 전용 endpoint (2026-05-24)
|
||||
|
||||
PWA 스캐폴드에서 클라이언트가 `agentId` 를 쿼리 파라미터로 보내 본인 데이터를 필터하던 변조 가능 구조를 **서버측 강제 필터**로 교체. 토큰 → user → agentId 자동 추출.
|
||||
|
||||
### 백엔드 신규
|
||||
| 위치 | 내용 |
|
||||
|---|---|
|
||||
| `ga-api/service/mobile/MobileMeService` | 토큰에서 userId 추출, `userMapper.selectById` 로 agentId 결정. 클라이언트 param 의 agentId/userId 는 **무시하고 덮어쓰기**. agentId 미연결 시 `BizException(FORBIDDEN)` |
|
||||
| `ga-api/controller/mobile/MobileMeController` | `GET /api/mobile/me/{summary,statements,withdraw-requests,notifications}` + `POST /api/mobile/me/notifications/{id}/read`. 별도 메뉴 권한 없이 인증만 통과 |
|
||||
|
||||
### PWA 측 전환
|
||||
- `src/api/statement.ts` / `withdraw.ts` → `/api/mobile/me/*` 호출, `agentId` 인터페이스에서 제거
|
||||
- `src/api/notification.ts` 신규 — list/markRead/summary
|
||||
- `Home.tsx` — `notificationApi.summary()` 한 번으로 3 카운트 동시 표시, 안 읽음 뱃지
|
||||
- `NoticeList.tsx` — `Tabs` 로 `내 알림` / `공지사항` 두 탭. 알림 클릭 시 markRead + summary 무효화
|
||||
- `StatementList.tsx` / `WithdrawList.tsx` — agentId 의존 제거, E403(설계사 미연결) 시 ErrorBlock 안내
|
||||
|
||||
### 라이브 검증 (2026-05-24)
|
||||
- ga-api 컴파일 BUILD SUCCESSFUL · 재기동 OK
|
||||
- PWA `tsc --noEmit` PASS · `npm run build` PASS (precache 8 entries 538 KiB, gzip 176KB)
|
||||
- `GET /api/mobile/me/summary` (admin, agentId=null) → 200 OK, 세 카운트 모두 0
|
||||
- `GET /api/mobile/me/statements` (admin) → **E403** 차단 (보안 강제 동작 확인)
|
||||
- `GET /api/mobile/me/notifications` (admin) → 200 OK total=0
|
||||
|
||||
## 4. 잔여 작업 (P6 실연동)
|
||||
|
||||
P5 전체 + P6 PoC(어댑터 모듈 + Mock + SDK 분기 + 재시도 + 추적 컬럼 + 호출 감사 이력 + **모바일 PWA 스캐폴드**) 완료.
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package com.ga.api.controller.mobile;
|
||||
|
||||
import com.ga.api.service.mobile.MobileMeService;
|
||||
import com.ga.api.service.mobile.MobileMeService.MeSummary;
|
||||
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.withdraw.WithdrawRequestResp;
|
||||
import com.ga.core.vo.withdraw.WithdrawRequestSearchParam;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* 모바일 PWA 전용 — 토큰의 사용자 본인 데이터만 반환.
|
||||
*
|
||||
* <p>모든 endpoint 는 인증만 통과하면 사용 가능 (별도 메뉴 권한 없음).
|
||||
* agentId / userId 는 서버측에서 토큰 → user 조회로 강제 결정. 클라이언트 파라미터는 무시한다.
|
||||
*/
|
||||
@Tag(name = "모바일 셀프 조회")
|
||||
@RestController
|
||||
@RequestMapping("/api/mobile/me")
|
||||
@RequiredArgsConstructor
|
||||
public class MobileMeController {
|
||||
|
||||
private final MobileMeService service;
|
||||
|
||||
@GetMapping("/summary")
|
||||
public ApiResponse<MeSummary> summary() {
|
||||
return ApiResponse.ok(service.summary());
|
||||
}
|
||||
|
||||
@GetMapping("/statements")
|
||||
public ApiResponse<PageResponse<CommissionStatementResp>> statements(
|
||||
@ModelAttribute CommissionStatementSearchParam param) {
|
||||
return ApiResponse.ok(service.statements(param));
|
||||
}
|
||||
|
||||
@GetMapping("/withdraw-requests")
|
||||
public ApiResponse<PageResponse<WithdrawRequestResp>> withdrawRequests(
|
||||
@ModelAttribute WithdrawRequestSearchParam param) {
|
||||
return ApiResponse.ok(service.withdrawRequests(param));
|
||||
}
|
||||
|
||||
@GetMapping("/notifications")
|
||||
public ApiResponse<PageResponse<UserNotificationResp>> notifications(
|
||||
@ModelAttribute UserNotificationSearchParam param) {
|
||||
return ApiResponse.ok(service.notifications(param));
|
||||
}
|
||||
|
||||
@PostMapping("/notifications/{notificationId}/read")
|
||||
public ApiResponse<Void> markRead(@PathVariable Long notificationId) {
|
||||
service.markNotificationRead(notificationId);
|
||||
return ApiResponse.ok();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package com.ga.api.service.mobile;
|
||||
|
||||
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.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.user.UserVO;
|
||||
import com.ga.core.vo.withdraw.WithdrawRequestResp;
|
||||
import com.ga.core.vo.withdraw.WithdrawRequestSearchParam;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* 모바일 PWA 전용 — 본인 데이터만 조회. agentId/userId 는 토큰에서 강제 추출.
|
||||
*
|
||||
* <p>클라이언트가 보낸 search param 의 agentId/userId 는 무시하고 SecurityContext 의 사용자로 덮어쓴다.
|
||||
* 본 서비스 외부에서는 본인 데이터 임의 조회가 절대 불가능하도록 보장.
|
||||
*/
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class MobileMeService {
|
||||
|
||||
private final UserMapper userMapper;
|
||||
private final CommissionStatementMapper statementMapper;
|
||||
private final WithdrawRequestMapper withdrawMapper;
|
||||
private final UserNotificationMapper notificationMapper;
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public MeSummary summary() {
|
||||
Long userId = currentUserId();
|
||||
Long agentId = resolveAgentId(userId);
|
||||
|
||||
long stTotal = 0L;
|
||||
long wdTotal = 0L;
|
||||
if (agentId != null) {
|
||||
CommissionStatementSearchParam sp = new CommissionStatementSearchParam();
|
||||
sp.setAgentId(agentId);
|
||||
sp.setPageNum(1);
|
||||
sp.setPageSize(1);
|
||||
sp.startPage();
|
||||
stTotal = PageResponse.of(statementMapper.selectList(sp)).getTotal();
|
||||
|
||||
WithdrawRequestSearchParam wp = new WithdrawRequestSearchParam();
|
||||
wp.setAgentId(agentId);
|
||||
wp.setPageNum(1);
|
||||
wp.setPageSize(1);
|
||||
wp.startPage();
|
||||
wdTotal = PageResponse.of(withdrawMapper.selectList(wp)).getTotal();
|
||||
}
|
||||
int unreadCount = notificationMapper.selectUnreadCount(userId);
|
||||
|
||||
return MeSummary.builder()
|
||||
.userId(userId)
|
||||
.agentId(agentId)
|
||||
.statementCount(stTotal)
|
||||
.withdrawRequestCount(wdTotal)
|
||||
.unreadNotificationCount(unreadCount)
|
||||
.build();
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<CommissionStatementResp> statements(CommissionStatementSearchParam param) {
|
||||
Long agentId = requireAgentId();
|
||||
param.setAgentId(agentId); // 강제 덮어쓰기
|
||||
param.startPage();
|
||||
return PageResponse.of(statementMapper.selectList(param));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<WithdrawRequestResp> withdrawRequests(WithdrawRequestSearchParam param) {
|
||||
Long agentId = requireAgentId();
|
||||
param.setAgentId(agentId); // 강제 덮어쓰기
|
||||
param.startPage();
|
||||
return PageResponse.of(withdrawMapper.selectList(param));
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<UserNotificationResp> notifications(UserNotificationSearchParam param) {
|
||||
Long userId = currentUserId();
|
||||
param.setUserId(userId); // 강제 덮어쓰기
|
||||
param.startPage();
|
||||
return PageResponse.of(notificationMapper.selectList(param));
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public void markNotificationRead(Long notificationId) {
|
||||
Long userId = currentUserId();
|
||||
notificationMapper.markRead(notificationId, userId);
|
||||
}
|
||||
|
||||
// ── private ───────────────────────────────────────────────────────────────
|
||||
|
||||
private Long currentUserId() {
|
||||
Long uid = SecurityUtil.getCurrentUserId();
|
||||
if (uid == null) throw new BizException(ErrorCode.UNAUTHORIZED);
|
||||
return uid;
|
||||
}
|
||||
|
||||
private Long resolveAgentId(Long userId) {
|
||||
UserVO user = userMapper.selectById(userId);
|
||||
return user != null ? user.getAgentId() : null;
|
||||
}
|
||||
|
||||
/** 본인 agent 가 연결돼 있어야만 통과. 미연결이면 FORBIDDEN. */
|
||||
private Long requireAgentId() {
|
||||
Long uid = currentUserId();
|
||||
Long agentId = resolveAgentId(uid);
|
||||
if (agentId == null) throw new BizException(ErrorCode.FORBIDDEN);
|
||||
return agentId;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
public static class MeSummary {
|
||||
private Long userId;
|
||||
private Long agentId;
|
||||
private long statementCount;
|
||||
private long withdrawRequestCount;
|
||||
private int unreadNotificationCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import api, { unwrap, PageResponse } from './request';
|
||||
|
||||
export interface NotificationResp extends Record<string, unknown> {
|
||||
notificationId: number;
|
||||
userId: number;
|
||||
title: string;
|
||||
content?: string;
|
||||
refType?: string;
|
||||
refId?: number;
|
||||
isRead: boolean;
|
||||
readAt?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export interface NotificationSearchParam {
|
||||
isRead?: boolean;
|
||||
refType?: string;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface MeSummary {
|
||||
userId: number;
|
||||
agentId?: number;
|
||||
statementCount: number;
|
||||
withdrawRequestCount: number;
|
||||
unreadNotificationCount: number;
|
||||
}
|
||||
|
||||
export const notificationApi = {
|
||||
list: (params: NotificationSearchParam = {}) =>
|
||||
unwrap<PageResponse<NotificationResp>>(api.get('/api/mobile/me/notifications', { params })),
|
||||
markRead: (id: number) =>
|
||||
unwrap<void>(api.post(`/api/mobile/me/notifications/${id}/read`, {})),
|
||||
summary: () => unwrap<MeSummary>(api.get('/api/mobile/me/summary')),
|
||||
};
|
||||
@@ -16,7 +16,6 @@ export interface StatementResp extends Record<string, unknown> {
|
||||
}
|
||||
|
||||
export interface StatementSearchParam {
|
||||
agentId?: number;
|
||||
settleMonth?: string;
|
||||
statementType?: string;
|
||||
issueStatus?: string;
|
||||
@@ -25,8 +24,7 @@ export interface StatementSearchParam {
|
||||
}
|
||||
|
||||
export const statementApi = {
|
||||
/** 본인 명세서 — 서버측이 토큰 → agentId 강제 필터. */
|
||||
list: (params: StatementSearchParam = {}) =>
|
||||
unwrap<PageResponse<StatementResp>>(api.get('/api/statements', { params })),
|
||||
detail: (id: number) =>
|
||||
unwrap<StatementResp>(api.get(`/api/statements/${id}`)),
|
||||
unwrap<PageResponse<StatementResp>>(api.get('/api/mobile/me/statements', { params })),
|
||||
};
|
||||
|
||||
@@ -20,28 +20,13 @@ export interface WithdrawResp extends Record<string, unknown> {
|
||||
}
|
||||
|
||||
export interface WithdrawSearchParam {
|
||||
agentId?: number;
|
||||
settleMasterId?: number;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface WithdrawSaveReq {
|
||||
settleMasterId: number;
|
||||
agentId: number;
|
||||
amount: number;
|
||||
requestedBy: number;
|
||||
bankCode: string;
|
||||
accountNo: string;
|
||||
}
|
||||
|
||||
export const withdrawApi = {
|
||||
/** 본인 출금 신청 이력 — 서버측이 토큰 → agentId 강제 필터. */
|
||||
list: (params: WithdrawSearchParam = {}) =>
|
||||
unwrap<PageResponse<WithdrawResp>>(api.get('/api/withdraw-requests', { params })),
|
||||
detail: (id: number) =>
|
||||
unwrap<WithdrawResp>(api.get(`/api/withdraw-requests/${id}`)),
|
||||
create: (req: WithdrawSaveReq) =>
|
||||
unwrap<number>(api.post('/api/withdraw-requests', req)),
|
||||
cancel: (id: number) =>
|
||||
unwrap<void>(api.post(`/api/withdraw-requests/${id}/cancel`, {})),
|
||||
unwrap<PageResponse<WithdrawResp>>(api.get('/api/mobile/me/withdraw-requests', { params })),
|
||||
};
|
||||
|
||||
@@ -2,31 +2,22 @@ import { Card, List, NavBar, Space, Tag } from 'antd-mobile';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { statementApi } from '@/api/statement';
|
||||
import { withdrawApi } from '@/api/withdraw';
|
||||
import { noticeApi } from '@/api/notice';
|
||||
import { notificationApi } from '@/api/notification';
|
||||
|
||||
export default function Home() {
|
||||
const navigate = useNavigate();
|
||||
const profile = useAuthStore((s) => s.profile);
|
||||
const clear = useAuthStore((s) => s.clear);
|
||||
const agentId = profile?.agentId;
|
||||
|
||||
const statements = useQuery({
|
||||
queryKey: ['home.statements', agentId],
|
||||
enabled: !!agentId,
|
||||
queryFn: () => statementApi.list({ agentId, pageNum: 1, pageSize: 1 }),
|
||||
});
|
||||
const withdraws = useQuery({
|
||||
queryKey: ['home.withdraws', agentId],
|
||||
enabled: !!agentId,
|
||||
queryFn: () => withdrawApi.list({ agentId, pageNum: 1, pageSize: 1 }),
|
||||
});
|
||||
const notices = useQuery({
|
||||
queryKey: ['home.notices'],
|
||||
queryFn: () => noticeApi.active(),
|
||||
const summary = useQuery({
|
||||
queryKey: ['mobile.me.summary'],
|
||||
queryFn: () => notificationApi.summary(),
|
||||
staleTime: 10_000,
|
||||
});
|
||||
|
||||
const s = summary.data;
|
||||
const loading = summary.isLoading;
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<NavBar back={null} right={<a onClick={() => { clear(); navigate('/login'); }} style={{ fontSize: 14 }}>로그아웃</a>}>
|
||||
@@ -40,7 +31,7 @@ export default function Home() {
|
||||
<div style={{ color: '#888', fontSize: 13, marginTop: 4 }}>
|
||||
{profile?.agentName ? `${profile.agentName} · ${profile.orgName ?? ''}` : '설계사 정보 없음'}
|
||||
</div>
|
||||
{profile?.roleCodes && (
|
||||
{profile?.roleCodes && profile.roleCodes.length > 0 && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Space wrap>
|
||||
{profile.roleCodes.map((r) => <Tag key={r} color="primary">{r}</Tag>)}
|
||||
@@ -52,32 +43,38 @@ export default function Home() {
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<List header="요약">
|
||||
<List.Item
|
||||
extra={statements.isLoading ? '...' : `총 ${statements.data?.total ?? 0}건`}
|
||||
extra={loading ? '...' : `총 ${s?.statementCount ?? 0}건`}
|
||||
onClick={() => navigate('/statement')}
|
||||
clickable
|
||||
>
|
||||
정산 명세서
|
||||
</List.Item>
|
||||
<List.Item
|
||||
extra={withdraws.isLoading ? '...' : `총 ${withdraws.data?.total ?? 0}건`}
|
||||
extra={loading ? '...' : `총 ${s?.withdrawRequestCount ?? 0}건`}
|
||||
onClick={() => navigate('/withdraw')}
|
||||
clickable
|
||||
>
|
||||
출금 신청
|
||||
</List.Item>
|
||||
<List.Item
|
||||
extra={notices.isLoading ? '...' : `${notices.data?.length ?? 0}건`}
|
||||
extra={
|
||||
loading
|
||||
? '...'
|
||||
: (s?.unreadNotificationCount ?? 0) > 0
|
||||
? <Tag color="danger">{s?.unreadNotificationCount}건 안 읽음</Tag>
|
||||
: '모두 확인'
|
||||
}
|
||||
onClick={() => navigate('/notice')}
|
||||
clickable
|
||||
>
|
||||
공지사항
|
||||
알림 / 공지
|
||||
</List.Item>
|
||||
</List>
|
||||
</div>
|
||||
|
||||
{!agentId && (
|
||||
{s && !s.agentId && (
|
||||
<div style={{ marginTop: 24, fontSize: 12, color: '#f96', textAlign: 'center' }}>
|
||||
본 계정에 연결된 설계사(agent)가 없습니다. 일부 기능이 제한될 수 있습니다.
|
||||
본 계정에 연결된 설계사(agent)가 없습니다. 명세서·출금 조회가 제한됩니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,40 +1,112 @@
|
||||
import { ErrorBlock, List, NavBar, PullToRefresh, Tag } from 'antd-mobile';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Badge, ErrorBlock, List, NavBar, PullToRefresh, Tabs, Tag } from 'antd-mobile';
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { NoticeResp, noticeApi } from '@/api/notice';
|
||||
import { NotificationResp, notificationApi } from '@/api/notification';
|
||||
|
||||
export default function NoticeList() {
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['notices.active'],
|
||||
queryFn: () => noticeApi.active(),
|
||||
});
|
||||
|
||||
const items = data ?? [];
|
||||
const [tab, setTab] = useState('notifications');
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<NavBar back={null}>공지사항</NavBar>
|
||||
<PullToRefresh onRefresh={async () => { await refetch(); }}>
|
||||
<div style={{ padding: 0, paddingTop: 8 }}>
|
||||
{!isLoading && items.length === 0 && (
|
||||
<ErrorBlock status="empty" title="활성 공지가 없습니다" />
|
||||
)}
|
||||
{items.length > 0 && (
|
||||
<List>
|
||||
{items.map((n: NoticeResp) => (
|
||||
<List.Item
|
||||
key={n.noticeId}
|
||||
title={n.title}
|
||||
description={
|
||||
<span style={{ fontSize: 12 }}>
|
||||
{n.startAt ?? ''} {n.noticeType && <Tag style={{ marginLeft: 6 }} color="default">{n.noticeType}</Tag>}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
<NavBar back={null}>알림 / 공지</NavBar>
|
||||
<Tabs activeKey={tab} onChange={setTab}>
|
||||
<Tabs.Tab title="내 알림" key="notifications">
|
||||
<Notifications />
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab title="공지사항" key="notices">
|
||||
<Notices />
|
||||
</Tabs.Tab>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Notifications() {
|
||||
const qc = useQueryClient();
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['mobile.me.notifications'],
|
||||
queryFn: () => notificationApi.list({ pageNum: 1, pageSize: 50 }),
|
||||
});
|
||||
const items = data?.list ?? [];
|
||||
|
||||
const onItemClick = async (n: NotificationResp) => {
|
||||
if (!n.isRead) {
|
||||
try {
|
||||
await notificationApi.markRead(n.notificationId);
|
||||
await qc.invalidateQueries({ queryKey: ['mobile.me.notifications'] });
|
||||
await qc.invalidateQueries({ queryKey: ['mobile.me.summary'] });
|
||||
} catch {
|
||||
// interceptor 가 토스트 처리
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={async () => { await refetch(); }}>
|
||||
<div style={{ paddingTop: 4 }}>
|
||||
{!isLoading && items.length === 0 && (
|
||||
<ErrorBlock status="empty" title="알림이 없습니다" />
|
||||
)}
|
||||
{items.length > 0 && (
|
||||
<List>
|
||||
{items.map((n: NotificationResp) => (
|
||||
<List.Item
|
||||
key={n.notificationId}
|
||||
onClick={() => onItemClick(n)}
|
||||
clickable
|
||||
title={
|
||||
<span>
|
||||
{!n.isRead && <Badge color="#ff3141" style={{ marginRight: 6 }} />}
|
||||
{n.title}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
<span style={{ fontSize: 12 }}>
|
||||
{n.refType && <Tag color="default" style={{ marginRight: 6 }}>{n.refType}</Tag>}
|
||||
{n.content ?? ''}
|
||||
</span>
|
||||
}
|
||||
extra={<span style={{ fontSize: 11, color: '#aaa' }}>{n.createdAt ?? ''}</span>}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
function Notices() {
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['mobile.notices.active'],
|
||||
queryFn: () => noticeApi.active(),
|
||||
});
|
||||
const items = data ?? [];
|
||||
|
||||
return (
|
||||
<PullToRefresh onRefresh={async () => { await refetch(); }}>
|
||||
<div style={{ paddingTop: 4 }}>
|
||||
{!isLoading && items.length === 0 && (
|
||||
<ErrorBlock status="empty" title="활성 공지가 없습니다" />
|
||||
)}
|
||||
{items.length > 0 && (
|
||||
<List>
|
||||
{items.map((n: NoticeResp) => (
|
||||
<List.Item
|
||||
key={n.noticeId}
|
||||
title={n.title}
|
||||
description={
|
||||
<span style={{ fontSize: 12 }}>
|
||||
{n.startAt ?? ''}{' '}
|
||||
{n.noticeType && <Tag style={{ marginLeft: 6 }} color="default">{n.noticeType}</Tag>}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,27 +1,26 @@
|
||||
import { ErrorBlock, List, NavBar, PullToRefresh, Tag } from 'antd-mobile';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { StatementResp, statementApi } from '@/api/statement';
|
||||
|
||||
export default function StatementList() {
|
||||
const agentId = useAuthStore((s) => s.profile?.agentId);
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['statements', agentId],
|
||||
enabled: !!agentId,
|
||||
queryFn: () => statementApi.list({ agentId, pageNum: 1, pageSize: 50 }),
|
||||
const { data, isLoading, refetch, error } = useQuery({
|
||||
queryKey: ['mobile.me.statements'],
|
||||
queryFn: () => statementApi.list({ pageNum: 1, pageSize: 50 }),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const items = data?.list ?? [];
|
||||
const forbidden = (error as { code?: string } | undefined)?.code === 'E403';
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<NavBar back={null}>정산 명세서</NavBar>
|
||||
<PullToRefresh onRefresh={async () => { await refetch(); }}>
|
||||
<div style={{ padding: 0, paddingTop: 8 }}>
|
||||
{!agentId && (
|
||||
<div style={{ paddingTop: 8 }}>
|
||||
{forbidden && (
|
||||
<ErrorBlock status="empty" title="설계사 미연결" description="본 계정에는 설계사가 연결되어 있지 않습니다." />
|
||||
)}
|
||||
{agentId && !isLoading && items.length === 0 && (
|
||||
{!forbidden && !isLoading && items.length === 0 && (
|
||||
<ErrorBlock status="empty" title="명세서가 없습니다" description="발급된 정산 명세서가 아직 없습니다." />
|
||||
)}
|
||||
{items.length > 0 && (
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { ErrorBlock, List, NavBar, PullToRefresh, Tag } from 'antd-mobile';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { WithdrawResp, withdrawApi } from '@/api/withdraw';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
@@ -13,24 +12,24 @@ const STATUS_COLOR: Record<string, string> = {
|
||||
};
|
||||
|
||||
export default function WithdrawList() {
|
||||
const agentId = useAuthStore((s) => s.profile?.agentId);
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['withdraws', agentId],
|
||||
enabled: !!agentId,
|
||||
queryFn: () => withdrawApi.list({ agentId, pageNum: 1, pageSize: 50 }),
|
||||
const { data, isLoading, refetch, error } = useQuery({
|
||||
queryKey: ['mobile.me.withdraws'],
|
||||
queryFn: () => withdrawApi.list({ pageNum: 1, pageSize: 50 }),
|
||||
retry: false,
|
||||
});
|
||||
|
||||
const items = data?.list ?? [];
|
||||
const forbidden = (error as { code?: string } | undefined)?.code === 'E403';
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<NavBar back={null}>출금 신청</NavBar>
|
||||
<PullToRefresh onRefresh={async () => { await refetch(); }}>
|
||||
<div style={{ padding: 0, paddingTop: 8 }}>
|
||||
{!agentId && (
|
||||
<div style={{ paddingTop: 8 }}>
|
||||
{forbidden && (
|
||||
<ErrorBlock status="empty" title="설계사 미연결" description="본 계정에는 설계사가 연결되어 있지 않습니다." />
|
||||
)}
|
||||
{agentId && !isLoading && items.length === 0 && (
|
||||
{!forbidden && !isLoading && items.length === 0 && (
|
||||
<ErrorBlock status="empty" title="출금 신청 내역이 없습니다" />
|
||||
)}
|
||||
{items.length > 0 && (
|
||||
@@ -38,11 +37,7 @@ export default function WithdrawList() {
|
||||
{items.map((w: WithdrawResp) => (
|
||||
<List.Item
|
||||
key={w.requestId}
|
||||
title={
|
||||
<span>
|
||||
#{w.requestId} {w.settleMonth ?? ''}
|
||||
</span>
|
||||
}
|
||||
title={<span>#{w.requestId} {w.settleMonth ?? ''}</span>}
|
||||
description={
|
||||
<span style={{ fontSize: 12 }}>
|
||||
{w.bankCode ?? '-'} · {w.accountNo ?? '-'} · {w.amount.toLocaleString()}원
|
||||
|
||||
Reference in New Issue
Block a user