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:
GA Pro
2026-05-24 23:22:18 +09:00
parent 4a7a4eeb7a
commit 23fdd8474b
26 changed files with 7701 additions and 1 deletions
+56 -1
View File
@@ -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 실연동 차단 사유)**:
+50
View File
@@ -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)
+16
View File
@@ -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>
+6749
View File
File diff suppressed because it is too large Load Diff
+31
View File
@@ -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"
}
}
+4
View File
@@ -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

+4
View File
@@ -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

+4
View File
@@ -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

+57
View File
@@ -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>
);
}
+39
View File
@@ -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),
};
+18
View File
@@ -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}`)),
};
+63
View File
@@ -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;
}
+32
View File
@@ -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}`)),
};
+47
View File
@@ -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}</>;
}
+36
View File
@@ -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>
);
}
+22
View File
@@ -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>,
);
+86
View File
@@ -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>
);
}
+60
View File
@@ -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>
);
}
+40
View File
@@ -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>
);
}
+57
View File
@@ -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();
}
+61
View File
@@ -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>
);
}
+30
View File
@@ -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' },
),
);
+23
View File
@@ -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;
}
+23
View File
@@ -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"]
}
+73
View File
@@ -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,
},
});