feat: P6 모바일 셀프 조회 PWA 스캐폴드 (ga-mobile-pwa)
설계사 본인 정산명세서/출금/공지를 모바일에서 조회하는 PWA 신규 모듈. 데스크탑 admin UI(ga-frontend)와 별도. 백엔드 무변경 — 기존 ga-api(8082) 재사용. 토큰 키도 mobile.accessToken 으로 분리. 스택: - Vite + React 18 + TS + antd-mobile@5 - vite-plugin-pwa (manifest + service worker autoUpdate) - React Query + Zustand (persist) + react-router-dom - 포트 3001, /api → 8082 dev 프록시 화면 5종 (NavBar + List + PullToRefresh + TabBar): - /login — POST /api/auth/login + GET /api/auth/me - / — 홈 (요약 카드 + 메뉴, 로그아웃) - /statement — GET /api/statements?agentId=… (본인 명세서) - /withdraw — GET /api/withdraw-requests?agentId=… (본인 출금이력) - /notice — GET /api/notices/active PWA: - manifest: name/icon/theme/standalone, ko, / - /api/** NetworkFirst 5s 타임아웃 / 5분 캐시 (오프라인 시 직전 응답) - 아이콘은 SVG placeholder (192/512) — 본 배포 시 PNG 권장 검증: - npm install 407 packages - tsc --noEmit PASS - npm run build PASS — manifest.webmanifest + sw.js + workbox-*.js 자동 생성, precache 8 entries 528KB, 번들 gzip 173KB - vite dev :3001 LISTENING + ga-api :8082 정상 - /api/statements total=112, /api/withdraw-requests total=3, /api/notices/active 0건, /api/auth/me 정상 잔여 (스캐폴드 이후): - 백엔드 /api/mobile/me/* 전용 endpoint (서버측 본인 필터 강제) - 출금 신청 작성 폼, 명세서 상세, 본인 알림(user_notification) - 토큰 만료 자동 refresh, 푸시 알림 (FCM) - 실 아이콘 PNG, manualChunks 분리 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+56
-1
@@ -297,6 +297,61 @@ P5 전체 + P6 PoC(어댑터 모듈 + Mock 구현 + SDK 분기 + 재시도 배
|
||||
| 펌뱅킹 SETTLED 동기화 | ACCEPTED→SETTLED 폴링 스케줄 (queryStatus 사용) | 실 SDK 통합 이후 |
|
||||
| 모바일 셀프 조회 PWA | 설계사 전용. 정산 명세서/출금 신청/공지 등 | 화면 우선순위 협의 |
|
||||
|
||||
## 3-13. P6 — 모바일 셀프 조회 PWA 스캐폴드 (ga-mobile-pwa, 2026-05-24)
|
||||
|
||||
설계사 본인 정산명세서·출금·공지를 모바일에서 조회하는 PWA 신규 모듈. 데스크탑 admin UI(`ga-frontend`)와 별도. **백엔드 무변경** — 기존 `ga-api`(8082) 그대로 재사용.
|
||||
|
||||
### 구성
|
||||
| 항목 | 위치 | 비고 |
|
||||
|---|---|---|
|
||||
| 신규 모듈 | `ga-mobile-pwa/` | Vite + React 18 + TS + antd-mobile@5 |
|
||||
| PWA 인프라 | `vite-plugin-pwa` | manifest + service worker autoUpdate, `/api/**` NetworkFirst(5s/5min) |
|
||||
| 인증 | `src/stores/authStore.ts` (zustand persist) | 토큰 키: `mobile.accessToken` (`ga-frontend` 와 분리) |
|
||||
| API 클라이언트 | `src/api/{request,auth,statement,withdraw,notice}.ts` | axios + 401 자동 리다이렉트 |
|
||||
| 화면 5종 | `src/pages/{Login,Home,StatementList,WithdrawList,NoticeList}.tsx` | NavBar + List/Card + PullToRefresh + TabBar |
|
||||
| 라우팅 | `src/App.tsx` + `RequireAuth` 가드 + `MobileTabBar` | `/login` 외 모두 보호 |
|
||||
|
||||
### 화면 ↔ 백엔드 API
|
||||
| 경로 | 백엔드 |
|
||||
|---|---|
|
||||
| `/login` | POST `/api/auth/login` + GET `/api/auth/me` |
|
||||
| `/` (홈) | 세 도메인 카운트 조회 |
|
||||
| `/statement` | GET `/api/statements?agentId={me.agentId}` |
|
||||
| `/withdraw` | GET `/api/withdraw-requests?agentId={me.agentId}` |
|
||||
| `/notice` | GET `/api/notices/active` |
|
||||
|
||||
본인 데이터 필터: 클라이언트에서 `profile.agentId` 를 쿼리 파라미터로 전달. 본격 운영에는 **백엔드 `/api/mobile/me/*` 전용 endpoint** (토큰 → agentId 서버측 강제 필터) 권장 — 잔여 항목.
|
||||
|
||||
### 빌드 / 실행
|
||||
```bash
|
||||
cd ga-mobile-pwa
|
||||
npm install
|
||||
npm run dev # http://localhost:3001 (vite dev — /api → 8082 proxy)
|
||||
npm run build # tsc --noEmit + vite build → dist/ (manifest + sw 자동 생성)
|
||||
npm run preview # 3001 에서 production 번들 서빙
|
||||
```
|
||||
|
||||
### 통합 검증 (2026-05-24)
|
||||
- `npm install` 407 packages 성공
|
||||
- `tsc --noEmit` PASS (`@types/node` 추가)
|
||||
- `npm run build` PASS — `manifest.webmanifest` + `sw.js` + `workbox-*.js` 자동 생성, precache 8 entries (528 KiB), 번들 516KB(gzip 173KB)
|
||||
- 라이브: vite dev :3001 LISTENING, ga-api :8082 정상
|
||||
- API 응답 확인: `/api/statements` total=112, `/api/withdraw-requests` total=3, `/api/notices/active` 0건(정상), `/api/auth/me` 정상
|
||||
|
||||
### 잔여 (스캐폴드 이후)
|
||||
- 백엔드 `/api/mobile/me/*` 전용 endpoint (서버측 본인 필터 강제)
|
||||
- 출금 신청 작성 폼 (현재 이력 조회만)
|
||||
- 정산 명세서 상세 + PDF 다운로드
|
||||
- 본인 알림(`user_notification`) 표시 + 토큰 만료 자동 refresh
|
||||
- 푸시 알림 (FCM 등) — 외부 채널 결정 후
|
||||
- 실 아이콘 PNG (192/512/512 maskable)
|
||||
- 번들 코드 스플리팅 (manualChunks)
|
||||
|
||||
## 4. 잔여 작업 (P6 실연동)
|
||||
|
||||
P5 전체 + P6 PoC(어댑터 모듈 + Mock + SDK 분기 + 재시도 + 추적 컬럼 + 호출 감사 이력 + **모바일 PWA 스캐폴드**) 완료.
|
||||
잔여는 **외부 스펙 확정** 후 적용 가능한 항목.
|
||||
|
||||
### 운영 DB 마이그레이션 상태
|
||||
운영 DB `192.168.0.60:55432/trading_ai/ga` 현재 **V85 적용 완료** (2026-05-24).
|
||||
V79~V85 Flyway 자동 적용 확인. ga-api 8082 가동 중.
|
||||
@@ -354,7 +409,7 @@ V79~V85 Flyway 자동 적용 확인. ga-api 8082 가동 중.
|
||||
3. **HANDOFF.md 읽기**: 본 문서 + 최근 git log로 컨텍스트 확보
|
||||
4. **운영 DB 상태**: **V85 적용 완료**. ga-api 8082 가동 중 (필요 시 `python verify_v74.py` / `smoke_v78.py` 재실행)
|
||||
5. **권장 시작 지점 (외부 스펙 미정 시)**:
|
||||
- 모바일 셀프 조회 PWA 스캐폴드 (화면 우선순위 협의)
|
||||
- 모바일 PWA 후속: 백엔드 `/api/mobile/me/*` 전용 endpoint, 출금 신청 폼, 명세서 상세
|
||||
- 펌뱅킹 SETTLED 동기화 폴러 (Mock 검증 → 실 SDK 통합 시 가치)
|
||||
- 알림 채널 SMS phone 매핑 보강 (현재 user.phone 미설정 시 SMS skip — phone 등록 화면)
|
||||
6. **사용자 결정 필요 항목 (P6 실연동 차단 사유)**:
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
# ga-mobile-pwa
|
||||
|
||||
설계사 전용 모바일 셀프 조회 PWA 스캐폴드.
|
||||
|
||||
## 스택
|
||||
- Vite + React 18 + TypeScript
|
||||
- antd-mobile@5 (모바일 UI)
|
||||
- vite-plugin-pwa (manifest + service worker autoUpdate)
|
||||
- React Query (서버 상태) + Zustand (인증)
|
||||
- 백엔드: 기존 `ga-api`(8082) 그대로 재사용. 로그인 후 `/api/auth/me` 의 `agentId` 로 본인 데이터 필터링.
|
||||
|
||||
## 화면
|
||||
| 경로 | 화면 | 백엔드 API |
|
||||
|---|---|---|
|
||||
| `/login` | 로그인 | `POST /api/auth/login`, `GET /api/auth/me` |
|
||||
| `/` | 홈 (요약 카드 + 메뉴) | 위 3개 카운트 |
|
||||
| `/statement` | 정산 명세서 | `GET /api/statements?agentId=…` |
|
||||
| `/withdraw` | 출금 신청 이력 | `GET /api/withdraw-requests?agentId=…` |
|
||||
| `/notice` | 공지사항 | `GET /api/notices/active` |
|
||||
|
||||
## 실행
|
||||
```bash
|
||||
cd ga-mobile-pwa
|
||||
npm install
|
||||
npm run dev # http://localhost:3001
|
||||
```
|
||||
|
||||
`vite.config.ts` 의 `/api` 프록시가 `localhost:8082` 로 전달하므로 ga-api 가 가동 중이어야 한다.
|
||||
|
||||
## 빌드 / 프리뷰
|
||||
```bash
|
||||
npm run build # tsc --noEmit + vite build (dist/)
|
||||
npm run preview # 3001 에서 production 번들 서빙
|
||||
```
|
||||
|
||||
## PWA
|
||||
- `manifest.webmanifest` 자동 생성 (`vite-plugin-pwa`)
|
||||
- 서비스워커: `registerType=autoUpdate`. 신규 빌드 시 사용자 새로고침 시점에 갱신.
|
||||
- 런타임 캐시: `/api/**` NetworkFirst, 5초 타임아웃, 5분 유효 (오프라인 시 직전 응답 반환)
|
||||
- 아이콘: `public/pwa-{192,512}.svg` (스캐폴드 — 본 배포 시 PNG 권장)
|
||||
|
||||
## 잔여 작업 (스캐폴드 이후)
|
||||
- [ ] 로그인 응답에서 직접 profile 추출 (현재 `/me` 추가 호출)
|
||||
- [ ] 백엔드 `/api/mobile/me/*` 전용 endpoint — 본인 agentId 자동 필터 (현 클라이언트 필터는 변조 가능)
|
||||
- [ ] 출금 신청 작성 화면 (현재 이력 조회만)
|
||||
- [ ] 정산 명세서 상세 + PDF 다운로드
|
||||
- [ ] 공지 상세 + 본인 알림(`user_notification`) 표시
|
||||
- [ ] 토큰 만료 자동 refresh (`/api/auth/refresh`)
|
||||
- [ ] 푸시 알림 (FCM 등) — 외부 채널 결정 후
|
||||
- [ ] 실 아이콘 PNG (192/512/512 maskable)
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#1677ff" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<title>GA 설계사 셀프 조회</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+6749
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "ga-mobile-pwa",
|
||||
"private": true,
|
||||
"version": "0.1.0-alpha",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview --port 3001"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.40.0",
|
||||
"antd-mobile": "^5.37.1",
|
||||
"antd-mobile-icons": "^0.3.0",
|
||||
"axios": "^1.7.2",
|
||||
"dayjs": "^1.11.11",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.23.1",
|
||||
"zustand": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.12.12",
|
||||
"@types/react": "^18.3.3",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.4.5",
|
||||
"vite": "^5.2.11",
|
||||
"vite-plugin-pwa": "^0.20.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="6" fill="#1677ff"/>
|
||||
<text x="16" y="22" font-family="Arial, sans-serif" font-size="14" font-weight="700" text-anchor="middle" fill="#fff">GA</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 253 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 192 192">
|
||||
<rect width="192" height="192" rx="36" fill="#1677ff"/>
|
||||
<text x="96" y="120" font-family="Arial, sans-serif" font-size="78" font-weight="700" text-anchor="middle" fill="#fff">GA</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 259 B |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="96" fill="#1677ff"/>
|
||||
<text x="256" y="320" font-family="Arial, sans-serif" font-size="208" font-weight="700" text-anchor="middle" fill="#fff">GA</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 261 B |
@@ -0,0 +1,57 @@
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
import RequireAuth from '@/components/RequireAuth';
|
||||
import MobileTabBar from '@/components/TabBar';
|
||||
import Login from '@/pages/Login';
|
||||
import Home from '@/pages/Home';
|
||||
import StatementList from '@/pages/StatementList';
|
||||
import WithdrawList from '@/pages/WithdrawList';
|
||||
import NoticeList from '@/pages/NoticeList';
|
||||
|
||||
function AuthedLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
<MobileTabBar />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<AuthedLayout><Home /></AuthedLayout>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/statement"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<AuthedLayout><StatementList /></AuthedLayout>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/withdraw"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<AuthedLayout><WithdrawList /></AuthedLayout>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/notice"
|
||||
element={
|
||||
<RequireAuth>
|
||||
<AuthedLayout><NoticeList /></AuthedLayout>
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import api, { unwrap } from './request';
|
||||
|
||||
export interface LoginReq {
|
||||
loginId: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface LoginResp {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
user?: {
|
||||
userId: number;
|
||||
loginId: string;
|
||||
userName?: string;
|
||||
agentId?: number;
|
||||
roles?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface MeResp {
|
||||
userId: number;
|
||||
loginId: string;
|
||||
userName?: string;
|
||||
email?: string;
|
||||
phone?: string;
|
||||
agentId?: number;
|
||||
agentName?: string;
|
||||
orgName?: string;
|
||||
status?: string;
|
||||
roleCodes?: string[];
|
||||
roleNames?: string[];
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
login: (req: LoginReq) =>
|
||||
unwrap<LoginResp>(api.post('/api/auth/login', req)),
|
||||
me: () => unwrap<MeResp>(api.get('/api/auth/me')),
|
||||
logout: () => api.post('/api/auth/logout').catch(() => undefined),
|
||||
};
|
||||
@@ -0,0 +1,18 @@
|
||||
import api, { unwrap } from './request';
|
||||
|
||||
export interface NoticeResp extends Record<string, unknown> {
|
||||
noticeId: number;
|
||||
title: string;
|
||||
content?: string;
|
||||
noticeType?: string;
|
||||
startAt?: string;
|
||||
endAt?: string;
|
||||
isActive: boolean;
|
||||
createdAt?: string;
|
||||
createdByName?: string;
|
||||
}
|
||||
|
||||
export const noticeApi = {
|
||||
active: () => unwrap<NoticeResp[]>(api.get('/api/notices/active')),
|
||||
detail: (id: number) => unwrap<NoticeResp>(api.get(`/api/notices/${id}`)),
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import axios, { AxiosError } from 'axios';
|
||||
import { Toast } from 'antd-mobile';
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
code: string;
|
||||
message: string;
|
||||
data: T;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
export interface PageResponse<T> {
|
||||
list: T[];
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
pages: number;
|
||||
}
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_URL ?? '',
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = localStorage.getItem('mobile.accessToken');
|
||||
if (token) config.headers.Authorization = `Bearer ${token}`;
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(res) => {
|
||||
const body = res.data;
|
||||
if (body && typeof body === 'object' && 'success' in body && body.success === false) {
|
||||
Toast.show({ icon: 'fail', content: body.message ?? '오류가 발생했습니다' });
|
||||
return Promise.reject(body);
|
||||
}
|
||||
return res;
|
||||
},
|
||||
(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';
|
||||
}
|
||||
} else if (status === 403) {
|
||||
Toast.show({ icon: 'fail', content: body?.message ?? '권한이 없습니다' });
|
||||
} else {
|
||||
Toast.show({ icon: 'fail', content: body?.message ?? err.message ?? '서버 오류' });
|
||||
}
|
||||
return Promise.reject(err);
|
||||
},
|
||||
);
|
||||
|
||||
export default api;
|
||||
|
||||
export async function unwrap<T>(p: Promise<{ data: ApiResponse<T> }>): Promise<T> {
|
||||
const res = await p;
|
||||
return res.data.data;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import api, { unwrap, PageResponse } from './request';
|
||||
|
||||
export interface StatementResp extends Record<string, unknown> {
|
||||
statementId: number;
|
||||
agentId: number;
|
||||
agentName?: string;
|
||||
settleMonth: string;
|
||||
statementType: string;
|
||||
statementTypeName?: string;
|
||||
issueStatus?: string;
|
||||
issueStatusName?: string;
|
||||
totalCommission?: number;
|
||||
totalTax?: number;
|
||||
netAmount?: number;
|
||||
issuedAt?: string;
|
||||
}
|
||||
|
||||
export interface StatementSearchParam {
|
||||
agentId?: number;
|
||||
settleMonth?: string;
|
||||
statementType?: string;
|
||||
issueStatus?: string;
|
||||
pageNum?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export const statementApi = {
|
||||
list: (params: StatementSearchParam = {}) =>
|
||||
unwrap<PageResponse<StatementResp>>(api.get('/api/statements', { params })),
|
||||
detail: (id: number) =>
|
||||
unwrap<StatementResp>(api.get(`/api/statements/${id}`)),
|
||||
};
|
||||
@@ -0,0 +1,47 @@
|
||||
import api, { unwrap, PageResponse } from './request';
|
||||
|
||||
export interface WithdrawResp extends Record<string, unknown> {
|
||||
requestId: number;
|
||||
settleMasterId?: number;
|
||||
settleMonth?: string;
|
||||
agentId: number;
|
||||
agentName?: string;
|
||||
amount: number;
|
||||
bankCode?: string;
|
||||
accountNo?: string;
|
||||
status: string;
|
||||
statusName?: string;
|
||||
requestedAt?: string;
|
||||
sentAt?: string;
|
||||
completedAt?: string;
|
||||
failReason?: string;
|
||||
transferTxId?: string;
|
||||
transferStatus?: string;
|
||||
}
|
||||
|
||||
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 = {
|
||||
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`, {})),
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ReactNode, useEffect } from 'react';
|
||||
import { Navigate, useLocation } from 'react-router-dom';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
import { authApi } from '@/api/auth';
|
||||
|
||||
export default function RequireAuth({ children }: { children: ReactNode }) {
|
||||
const { accessToken, profile, setProfile, clear } = useAuthStore();
|
||||
const location = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
if (accessToken && !profile) {
|
||||
authApi.me().then(setProfile).catch(() => clear());
|
||||
}
|
||||
}, [accessToken, profile, setProfile, clear]);
|
||||
|
||||
if (!accessToken) {
|
||||
return <Navigate to="/login" state={{ from: location.pathname }} replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { TabBar } from 'antd-mobile';
|
||||
import { AppOutline, FileOutline, PayCircleOutline, SoundOutline } from 'antd-mobile-icons';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
|
||||
const tabs = [
|
||||
{ key: '/', title: '홈', icon: <AppOutline /> },
|
||||
{ key: '/statement', title: '명세서', icon: <FileOutline /> },
|
||||
{ key: '/withdraw', title: '출금', icon: <PayCircleOutline /> },
|
||||
{ key: '/notice', title: '공지', icon: <SoundOutline /> },
|
||||
];
|
||||
|
||||
export default function MobileTabBar() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const activeKey = tabs
|
||||
.map((t) => t.key)
|
||||
.reduce((acc, k) => (location.pathname === k || (k !== '/' && location.pathname.startsWith(k)) ? k : acc), '/');
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed', bottom: 0, left: 0, right: 0,
|
||||
background: '#fff', borderTop: '1px solid #eee',
|
||||
paddingBottom: 'env(safe-area-inset-bottom, 0)',
|
||||
zIndex: 50,
|
||||
}}
|
||||
>
|
||||
<TabBar activeKey={activeKey} onChange={(k) => navigate(k)}>
|
||||
{tabs.map((t) => (
|
||||
<TabBar.Item key={t.key} icon={t.icon} title={t.title} />
|
||||
))}
|
||||
</TabBar>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import App from './App';
|
||||
import './styles.css';
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { staleTime: 30_000, retry: 1, refetchOnWindowFocus: false },
|
||||
},
|
||||
});
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,86 @@
|
||||
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';
|
||||
|
||||
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(),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<NavBar back={null} right={<a onClick={() => { clear(); navigate('/login'); }} style={{ fontSize: 14 }}>로그아웃</a>}>
|
||||
홈
|
||||
</NavBar>
|
||||
<div style={{ padding: 16 }}>
|
||||
<Card>
|
||||
<div style={{ fontSize: 18, fontWeight: 700 }}>
|
||||
{profile?.userName ?? profile?.loginId ?? '사용자'} 님
|
||||
</div>
|
||||
<div style={{ color: '#888', fontSize: 13, marginTop: 4 }}>
|
||||
{profile?.agentName ? `${profile.agentName} · ${profile.orgName ?? ''}` : '설계사 정보 없음'}
|
||||
</div>
|
||||
{profile?.roleCodes && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Space wrap>
|
||||
{profile.roleCodes.map((r) => <Tag key={r} color="primary">{r}</Tag>)}
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<List header="요약">
|
||||
<List.Item
|
||||
extra={statements.isLoading ? '...' : `총 ${statements.data?.total ?? 0}건`}
|
||||
onClick={() => navigate('/statement')}
|
||||
clickable
|
||||
>
|
||||
정산 명세서
|
||||
</List.Item>
|
||||
<List.Item
|
||||
extra={withdraws.isLoading ? '...' : `총 ${withdraws.data?.total ?? 0}건`}
|
||||
onClick={() => navigate('/withdraw')}
|
||||
clickable
|
||||
>
|
||||
출금 신청
|
||||
</List.Item>
|
||||
<List.Item
|
||||
extra={notices.isLoading ? '...' : `${notices.data?.length ?? 0}건`}
|
||||
onClick={() => navigate('/notice')}
|
||||
clickable
|
||||
>
|
||||
공지사항
|
||||
</List.Item>
|
||||
</List>
|
||||
</div>
|
||||
|
||||
{!agentId && (
|
||||
<div style={{ marginTop: 24, fontSize: 12, color: '#f96', textAlign: 'center' }}>
|
||||
본 계정에 연결된 설계사(agent)가 없습니다. 일부 기능이 제한될 수 있습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { useState } from 'react';
|
||||
import { Button, Form, Input, NavBar, Toast } from 'antd-mobile';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { authApi } from '@/api/auth';
|
||||
import { useAuthStore } from '@/stores/authStore';
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation() as { state?: { from?: string } };
|
||||
const setSession = useAuthStore((s) => s.setSession);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const onFinish = async (values: { loginId: string; password: string }) => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const r = await authApi.login(values);
|
||||
const me = await authApi.me().catch(() => null);
|
||||
setSession(r.accessToken, me ?? {
|
||||
userId: 0, loginId: values.loginId,
|
||||
});
|
||||
Toast.show({ icon: 'success', content: '로그인 성공' });
|
||||
navigate(location.state?.from ?? '/', { replace: true });
|
||||
} catch {
|
||||
// request interceptor 가 토스트 처리
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="page" style={{ paddingBottom: 0 }}>
|
||||
<NavBar back={null}>GA 설계사 셀프 조회</NavBar>
|
||||
<div style={{ padding: 24 }}>
|
||||
<div style={{ fontSize: 22, fontWeight: 700, marginBottom: 8 }}>로그인</div>
|
||||
<div style={{ color: '#999', marginBottom: 24, fontSize: 13 }}>
|
||||
본인 정산 명세서·출금 신청·공지를 조회합니다.
|
||||
</div>
|
||||
<Form
|
||||
layout="vertical"
|
||||
onFinish={onFinish}
|
||||
footer={
|
||||
<Button block type="submit" color="primary" size="large" loading={submitting}>
|
||||
로그인
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Form.Item label="아이디" name="loginId" rules={[{ required: true, message: '아이디를 입력하세요' }]}>
|
||||
<Input placeholder="loginId" clearable />
|
||||
</Form.Item>
|
||||
<Form.Item label="비밀번호" name="password" rules={[{ required: true, message: '비밀번호를 입력하세요' }]}>
|
||||
<Input placeholder="********" type="password" clearable />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<div style={{ marginTop: 16, fontSize: 12, color: '#bbb', textAlign: 'center' }}>
|
||||
PWA · 홈 화면에 추가하면 앱처럼 사용할 수 있습니다.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { ErrorBlock, List, NavBar, PullToRefresh, Tag } from 'antd-mobile';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { NoticeResp, noticeApi } from '@/api/notice';
|
||||
|
||||
export default function NoticeList() {
|
||||
const { data, isLoading, refetch } = useQuery({
|
||||
queryKey: ['notices.active'],
|
||||
queryFn: () => noticeApi.active(),
|
||||
});
|
||||
|
||||
const items = data ?? [];
|
||||
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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 items = data?.list ?? [];
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<NavBar back={null}>정산 명세서</NavBar>
|
||||
<PullToRefresh onRefresh={async () => { await refetch(); }}>
|
||||
<div style={{ padding: 0, paddingTop: 8 }}>
|
||||
{!agentId && (
|
||||
<ErrorBlock status="empty" title="설계사 미연결" description="본 계정에는 설계사가 연결되어 있지 않습니다." />
|
||||
)}
|
||||
{agentId && !isLoading && items.length === 0 && (
|
||||
<ErrorBlock status="empty" title="명세서가 없습니다" description="발급된 정산 명세서가 아직 없습니다." />
|
||||
)}
|
||||
{items.length > 0 && (
|
||||
<List>
|
||||
{items.map((s: StatementResp) => (
|
||||
<List.Item
|
||||
key={s.statementId}
|
||||
title={
|
||||
<span>
|
||||
{s.settleMonth ?? '-'}{' '}
|
||||
<Tag color="default" style={{ marginLeft: 6 }}>{s.statementTypeName ?? s.statementType}</Tag>
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
<span style={{ fontSize: 12 }}>
|
||||
총수수료 {fmt(s.totalCommission)} · 원천세 {fmt(s.totalTax)} · 실수령 {fmt(s.netAmount)}
|
||||
</span>
|
||||
}
|
||||
extra={<Tag color={s.issueStatus === 'ISSUED' ? 'success' : 'warning'}>{s.issueStatusName ?? s.issueStatus ?? '-'}</Tag>}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function fmt(n?: number) {
|
||||
if (n == null) return '-';
|
||||
return n.toLocaleString();
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
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> = {
|
||||
REQUESTED: 'default',
|
||||
APPROVED: 'primary',
|
||||
SENT: 'success',
|
||||
COMPLETED: 'success',
|
||||
FAILED: 'danger',
|
||||
CANCELLED: 'default',
|
||||
};
|
||||
|
||||
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 items = data?.list ?? [];
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<NavBar back={null}>출금 신청</NavBar>
|
||||
<PullToRefresh onRefresh={async () => { await refetch(); }}>
|
||||
<div style={{ padding: 0, paddingTop: 8 }}>
|
||||
{!agentId && (
|
||||
<ErrorBlock status="empty" title="설계사 미연결" description="본 계정에는 설계사가 연결되어 있지 않습니다." />
|
||||
)}
|
||||
{agentId && !isLoading && items.length === 0 && (
|
||||
<ErrorBlock status="empty" title="출금 신청 내역이 없습니다" />
|
||||
)}
|
||||
{items.length > 0 && (
|
||||
<List>
|
||||
{items.map((w: WithdrawResp) => (
|
||||
<List.Item
|
||||
key={w.requestId}
|
||||
title={
|
||||
<span>
|
||||
#{w.requestId} {w.settleMonth ?? ''}
|
||||
</span>
|
||||
}
|
||||
description={
|
||||
<span style={{ fontSize: 12 }}>
|
||||
{w.bankCode ?? '-'} · {w.accountNo ?? '-'} · {w.amount.toLocaleString()}원
|
||||
{w.transferTxId && <> · txId={w.transferTxId}</>}
|
||||
</span>
|
||||
}
|
||||
extra={<Tag color={STATUS_COLOR[w.status] ?? 'default'}>{w.statusName ?? w.status}</Tag>}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</div>
|
||||
</PullToRefresh>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { create } from 'zustand';
|
||||
import { persist } from 'zustand/middleware';
|
||||
import { MeResp } from '../api/auth';
|
||||
|
||||
interface AuthState {
|
||||
accessToken: string | null;
|
||||
profile: MeResp | null;
|
||||
setSession: (token: string, profile: MeResp) => void;
|
||||
setProfile: (profile: MeResp) => void;
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
export const useAuthStore = create<AuthState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
accessToken: null,
|
||||
profile: null,
|
||||
setSession: (token, profile) => {
|
||||
localStorage.setItem('mobile.accessToken', token);
|
||||
set({ accessToken: token, profile });
|
||||
},
|
||||
setProfile: (profile) => set({ profile }),
|
||||
clear: () => {
|
||||
localStorage.removeItem('mobile.accessToken');
|
||||
set({ accessToken: null, profile: null });
|
||||
},
|
||||
}),
|
||||
{ name: 'mobile.profile' },
|
||||
),
|
||||
);
|
||||
@@ -0,0 +1,23 @@
|
||||
:root {
|
||||
--safe-top: env(safe-area-inset-top, 0);
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0);
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #f5f5f7;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Apple SD Gothic Neo",
|
||||
"Malgun Gothic", sans-serif;
|
||||
}
|
||||
|
||||
.page {
|
||||
padding-bottom: calc(56px + var(--safe-bottom));
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"types": ["vite/client", "vite-plugin-pwa/client"],
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["src/*"] }
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
import path from 'node:path';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.svg'],
|
||||
manifest: {
|
||||
name: 'GA 설계사 셀프 조회',
|
||||
short_name: 'GA Mobile',
|
||||
description: '설계사 본인 정산 명세서 / 출금 신청 / 공지 모바일 조회',
|
||||
theme_color: '#1677ff',
|
||||
background_color: '#ffffff',
|
||||
display: 'standalone',
|
||||
start_url: '/',
|
||||
lang: 'ko',
|
||||
icons: [
|
||||
{
|
||||
src: 'pwa-192x192.svg',
|
||||
sizes: '192x192',
|
||||
type: 'image/svg+xml',
|
||||
purpose: 'any',
|
||||
},
|
||||
{
|
||||
src: 'pwa-512x512.svg',
|
||||
sizes: '512x512',
|
||||
type: 'image/svg+xml',
|
||||
purpose: 'any maskable',
|
||||
},
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
navigateFallback: '/index.html',
|
||||
runtimeCaching: [
|
||||
{
|
||||
urlPattern: /\/api\//,
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'api-cache',
|
||||
networkTimeoutSeconds: 5,
|
||||
expiration: { maxEntries: 100, maxAgeSeconds: 60 * 5 },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
devOptions: {
|
||||
enabled: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3001,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8082',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user