2026-05-10 21:15:13 +09:00
|
|
|
|
# 06_FRONTEND_SPEC — ga-frontend (React 18)
|
|
|
|
|
|
|
|
|
|
|
|
> Vite + React 18 + TypeScript + Ant Design 5 + AG Grid Community + React Query + Zustand.
|
|
|
|
|
|
> ga-common 에서 작성한 공통 컴포넌트(02_COMMON_SPEC §10)를 반드시 재사용.
|
2026-05-10 21:21:13 +09:00
|
|
|
|
> **디자인 시스템**: `docs/09_UI_DESIGN.md` 함께 참조 (테마 토큰, 레이아웃 규격, 화면별 와이어프레임)
|
2026-05-10 21:15:13 +09:00
|
|
|
|
|
|
|
|
|
|
## 패키지 구조
|
|
|
|
|
|
|
|
|
|
|
|
```
|
|
|
|
|
|
ga-frontend/
|
|
|
|
|
|
├── package.json # AG Grid Community ^31.3, antd ^5.18, react-query ^5.40
|
|
|
|
|
|
├── vite.config.ts # /api → http://localhost:8080 proxy
|
|
|
|
|
|
├── tsconfig.json # strict: true
|
|
|
|
|
|
├── index.html
|
|
|
|
|
|
└── src/
|
|
|
|
|
|
├── main.tsx # ReactDOM.createRoot, QueryClientProvider, ConfigProvider(ko)
|
|
|
|
|
|
├── App.tsx # 라우터 + 전역 메시지/모달
|
|
|
|
|
|
├── api/
|
|
|
|
|
|
│ ├── request.ts # axios + 인터셉터 + ApiResponse 타입
|
|
|
|
|
|
│ ├── auth.ts, agent.ts, contract.ts, ledger.ts, settle.ts, payment.ts,
|
|
|
|
|
|
│ ├── system.ts, menu.ts, code.ts, file.ts, batch.ts
|
|
|
|
|
|
├── components/
|
|
|
|
|
|
│ └── common/
|
|
|
|
|
|
│ ├── CodeSelect.tsx, CodeBadge.tsx, MoneyText.tsx
|
|
|
|
|
|
│ ├── SearchForm.tsx, DataGrid.tsx
|
|
|
|
|
|
│ ├── PermissionButton.tsx, ExcelExportButton.tsx, PageContainer.tsx
|
|
|
|
|
|
├── hooks/
|
|
|
|
|
|
│ ├── useCommonCodes.ts, useMenuTree.ts, usePermission.ts, useSearchParam.ts
|
|
|
|
|
|
├── layouts/
|
|
|
|
|
|
│ └── MainLayout.tsx # Sider + Header + Content + Breadcrumb
|
|
|
|
|
|
├── pages/
|
|
|
|
|
|
│ ├── Dashboard.tsx
|
|
|
|
|
|
│ ├── LoginPage.tsx
|
|
|
|
|
|
│ ├── org/ # AgentList / AgentDetail / AgentForm
|
|
|
|
|
|
│ ├── product/ # ContractList
|
|
|
|
|
|
│ ├── ledger/ # RecruitLedger / ExceptionLedger
|
|
|
|
|
|
│ ├── settle/ # SettleList / BatchRun / PaymentList
|
|
|
|
|
|
│ └── system/ # UserList / RoleList / CodeList
|
|
|
|
|
|
├── router/
|
|
|
|
|
|
│ └── DynamicRouter.tsx # /api/menus/my → React.lazy 매핑
|
|
|
|
|
|
├── stores/
|
|
|
|
|
|
│ ├── authStore.ts (Zustand) # accessToken, user, login/logout
|
|
|
|
|
|
│ ├── menuStore.ts # 메뉴 트리 캐시
|
|
|
|
|
|
│ └── notificationStore.ts # 미읽음 카운트
|
|
|
|
|
|
├── types/
|
|
|
|
|
|
│ └── api.ts, domain.ts
|
|
|
|
|
|
└── utils/
|
|
|
|
|
|
└── formatMoney.ts, formatDate.ts, downloadFile.ts
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
`vite.config.ts`:
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
export default defineConfig({
|
|
|
|
|
|
plugins: [react()],
|
|
|
|
|
|
server: {
|
|
|
|
|
|
port: 3000,
|
|
|
|
|
|
proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: true } }
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 1. 공통 컴포넌트 / 훅
|
|
|
|
|
|
|
|
|
|
|
|
상세 시그니처는 `02_COMMON_SPEC.md §10` 참고. 핵심:
|
|
|
|
|
|
|
|
|
|
|
|
```tsx
|
|
|
|
|
|
// 공통코드 셀렉트
|
|
|
|
|
|
<CodeSelect groupCode="AGENT_STATUS" allowClear />
|
|
|
|
|
|
<CodeBadge groupCode="SETTLE_STATUS" value="CONFIRMED" />
|
|
|
|
|
|
<MoneyText value={123456} sign /> // +123,456 (음수 빨강)
|
|
|
|
|
|
<MonthPicker value={month} onChange={setMonth} />
|
|
|
|
|
|
|
|
|
|
|
|
// 검색폼 — conditions 배열로 자동 렌더링
|
|
|
|
|
|
<SearchForm
|
|
|
|
|
|
conditions={[
|
|
|
|
|
|
{ type: 'text', name: 'searchKeyword', label: '검색어' },
|
|
|
|
|
|
{ type: 'code', name: 'status', label: '상태', groupCode: 'AGENT_STATUS' },
|
|
|
|
|
|
{ type: 'dateRange', name: 'dateRange', label: '기간' },
|
|
|
|
|
|
{ type: 'month', name: 'settleMonth', label: '정산월' },
|
|
|
|
|
|
]}
|
|
|
|
|
|
onSearch={setParam} onReset={resetParam}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
// 권한 버튼 — perm 없으면 안 보임
|
|
|
|
|
|
<PermissionButton menuCode="ORG_AGENT" permCode="CREATE" type="primary"
|
|
|
|
|
|
onClick={() => navigate('new')}>등록</PermissionButton>
|
|
|
|
|
|
|
|
|
|
|
|
// 엑셀 다운로드 — 100만건 진행률 표시
|
|
|
|
|
|
<ExcelExportButton url="/api/agents/export" params={searchParam}
|
|
|
|
|
|
fileName="설계사목록" showProgress />
|
|
|
|
|
|
|
|
|
|
|
|
// AG Grid 서버사이드
|
|
|
|
|
|
<DataGrid columnDefs={cols} dataSource={(p) => agentApi.list(p)}
|
|
|
|
|
|
totalRow rowSelection="multiple" />
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
훅:
|
|
|
|
|
|
```tsx
|
|
|
|
|
|
const { data: codes } = useCommonCodes('AGENT_STATUS'); // React Query staleTime 10분
|
|
|
|
|
|
const { canCreate, canExport, canApprove } = usePermission('ORG_AGENT');
|
|
|
|
|
|
const [param, setParam] = useSearchParam({ status: '' }); // URL 동기화
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 2. AG Grid vs DataTable 사용 기준
|
|
|
|
|
|
|
|
|
|
|
|
| 화면 | 컴포넌트 | 이유 |
|
|
|
|
|
|
|---|---|---|
|
|
|
|
|
|
| 설계사 목록, 사용자 목록 | Ant DataTable | 단순 CRUD, < 100 행 |
|
|
|
|
|
|
| 계약 목록 | Ant DataTable | 검색 위주 |
|
|
|
|
|
|
| **모집/유지/예외 원장** | **AG Grid** | 1만~100만 행, 합계행, 셀 편집 |
|
|
|
|
|
|
| **정산 결과** | **AG Grid** | 설계사 × 항목, 합계행 |
|
|
|
|
|
|
| 수수료율 편집 | AG Grid | 인라인 셀 편집 |
|
|
|
|
|
|
| 지급 관리 | Ant DataTable | 단순 상태 추적 |
|
|
|
|
|
|
|
|
|
|
|
|
판단 규칙:
|
|
|
|
|
|
- 100 건 이상 → AG Grid
|
|
|
|
|
|
- 셀 편집 필요 → AG Grid
|
|
|
|
|
|
- 합계행 (`pinnedBottomRowData`) → AG Grid
|
|
|
|
|
|
- 그 외 단순 목록 → Ant DataTable
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 3. 동적 라우터 (`router/DynamicRouter.tsx`)
|
|
|
|
|
|
|
|
|
|
|
|
```tsx
|
|
|
|
|
|
export function DynamicRouter() {
|
|
|
|
|
|
const { menuTree } = useMenuTree(); // /api/menus/my
|
|
|
|
|
|
const flat = flatten(menuTree).filter(m => m.menuType === 'PAGE' && m.componentPath);
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Routes>
|
|
|
|
|
|
<Route path="/login" element={<LoginPage />} />
|
|
|
|
|
|
<Route element={<MainLayout />}>
|
|
|
|
|
|
<Route path="/" element={<Dashboard />} />
|
|
|
|
|
|
{flat.map(m => {
|
|
|
|
|
|
const Page = lazy(() => import(`../pages/${m.componentPath}.tsx`));
|
|
|
|
|
|
return (
|
|
|
|
|
|
<Route key={m.menuId} path={m.menuPath}
|
|
|
|
|
|
element={<Suspense fallback={<Spin />}><Page /></Suspense>} />
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
<Route path="*" element={<NotFound />} />
|
|
|
|
|
|
</Route>
|
|
|
|
|
|
</Routes>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
- 로그인 → `/api/menus/my` 호출 → Sider 메뉴 + 라우터 동적 등록
|
|
|
|
|
|
- `menu.component_path` 가 `org/AgentList` 면 `pages/org/AgentList.tsx` 로 lazy import
|
|
|
|
|
|
- 권한 없는 URL 진입 시 403
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 4. 화면 13개 (현재 구현됨)
|
|
|
|
|
|
|
|
|
|
|
|
### 1) `Dashboard.tsx` (`/`)
|
|
|
|
|
|
- 정산 요약 카드 4개: 모집 / 유지 / 예외 / 환수 (월간 합계)
|
|
|
|
|
|
- 배치 진행 상황 (최근 monthlySettlementJob)
|
|
|
|
|
|
- 미해결 대사 오류 알림
|
|
|
|
|
|
- 월별 추이 (recharts `LineChart`)
|
|
|
|
|
|
|
|
|
|
|
|
### 2) `LoginPage.tsx` (`/login`)
|
|
|
|
|
|
- 로그인 폼 (`loginId`, `password`)
|
|
|
|
|
|
- 성공 시 토큰 저장 + 메뉴 트리 fetch + `/` 리다이렉트
|
|
|
|
|
|
|
|
|
|
|
|
### 3~5) `org/AgentList.tsx`, `AgentDetail.tsx`, `AgentForm.tsx` (`/org/agents`)
|
|
|
|
|
|
- 목록 (DataTable, 50명 더미 표시)
|
|
|
|
|
|
- SearchForm: 이름/상태/조직
|
|
|
|
|
|
- 상세 모달 또는 페이지 (탭: 기본정보/조직이력/계약/정산이력)
|
|
|
|
|
|
- 등록/수정 폼 (`AgentSaveReq`, Ant Form + `<Form.Item rules>`)
|
|
|
|
|
|
- 엑셀 다운로드 (`ExcelExportButton`)
|
|
|
|
|
|
|
|
|
|
|
|
### 6) `product/ContractList.tsx` (`/contracts`)
|
|
|
|
|
|
- 200건 더미 표시
|
|
|
|
|
|
- 필터: 상태(ACTIVE/LAPSE), 보험사, 보험종류, 기간
|
|
|
|
|
|
|
|
|
|
|
|
### 7) `ledger/RecruitLedger.tsx` (`/ledger/recruit`)
|
|
|
|
|
|
- AG Grid (서버사이드 페이징/정렬/필터)
|
|
|
|
|
|
- 합계행 (`pinnedBottomRowData`)
|
|
|
|
|
|
- 컬럼: 정산월, 증권번호, 설계사, 보험사, 보험료, 수수료율, 수수료, 세액
|
|
|
|
|
|
- 엑셀 다운로드
|
|
|
|
|
|
|
|
|
|
|
|
### 8) `ledger/ExceptionLedger.tsx` (`/ledger/exception`)
|
|
|
|
|
|
- AG Grid + 등록 모달 (수동 등록)
|
|
|
|
|
|
- 행 액션: 승인 / 반려 (`@RequirePermission perm=APPROVE`)
|
|
|
|
|
|
|
|
|
|
|
|
### 9) `settle/SettleList.tsx` (`/settle`)
|
|
|
|
|
|
- 월별 정산 요약
|
|
|
|
|
|
- 설계사별 카드 / AG Grid (모집/유지/예외/오버라이드/환수/총액/세금/실지급)
|
|
|
|
|
|
- 행 액션: 확정 / 보류
|
|
|
|
|
|
|
|
|
|
|
|
### 10) `settle/BatchRun.tsx` (`/settle/batch`)
|
|
|
|
|
|
- 정산월 선택 → 배치 실행 버튼
|
|
|
|
|
|
- 진행률 폴링 (5초 간격, `GET /api/batch/settlement/status`)
|
|
|
|
|
|
- 8 Step 별 진행 카운트 표시
|
|
|
|
|
|
|
|
|
|
|
|
### 11) `settle/PaymentList.tsx` (`/payments`)
|
|
|
|
|
|
- 지급 상태 (PENDING / SENT / SUCCESS / FAIL)
|
|
|
|
|
|
- 이체파일 생성 / 결과 등록
|
|
|
|
|
|
|
|
|
|
|
|
### 12) `system/UserList.tsx` (`/system/users`)
|
|
|
|
|
|
- 사용자 CRUD, 역할 할당 체크박스
|
|
|
|
|
|
- 비밀번호 초기화 / 잠금 해제 액션
|
|
|
|
|
|
|
|
|
|
|
|
### 13) `system/RoleList.tsx` (`/system/roles`)
|
|
|
|
|
|
- 역할 CRUD + 권한 매트릭스 (메뉴 × 6 권한 체크박스 그리드)
|
|
|
|
|
|
|
|
|
|
|
|
### `system/CodeList.tsx` (`/system/codes`)
|
|
|
|
|
|
- 좌측: 그룹 리스트 (DataTable)
|
|
|
|
|
|
- 우측: 선택한 그룹의 상세 코드 (DataTable)
|
|
|
|
|
|
- `is_system='Y'` 인 그룹은 수정/삭제 disable
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 5. 색상 / 디자인 규칙
|
|
|
|
|
|
|
|
|
|
|
|
- **상태 뱃지 색** (`CodeBadge` 자동):
|
|
|
|
|
|
- 초록: CONFIRMED / APPROVED / ACTIVE / PAID / SUCCESS
|
|
|
|
|
|
- 파랑: PENDING / CALCULATED / PROCESSING / SENT
|
|
|
|
|
|
- 빨강: HOLD / SUSPEND / REJECTED / FAIL / LAPSE / ERROR
|
|
|
|
|
|
- 회색: DRAFT / NEW / OPEN / IGNORED
|
|
|
|
|
|
- **금액**: `MoneyText` 사용 (양수 검정 / 음수 빨강 / null `-`)
|
|
|
|
|
|
- **마스킹**: 주민번호/계좌번호는 응답에서 마스킹돼 옴 (`@Mask` 어노테이션)
|
|
|
|
|
|
- **빈 상태**: `<Empty />` (Ant Design)
|
|
|
|
|
|
- **로딩**: `<Spin />`
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 6. Zustand Store 패턴
|
|
|
|
|
|
|
|
|
|
|
|
```typescript
|
|
|
|
|
|
interface AuthState {
|
|
|
|
|
|
accessToken: string | null;
|
|
|
|
|
|
user: { userId: number; loginId: string; userName: string; roleCodes: string[] } | null;
|
|
|
|
|
|
login: (token: string, user: any) => void;
|
|
|
|
|
|
logout: () => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
export const useAuthStore = create<AuthState>((set) => ({
|
|
|
|
|
|
accessToken: localStorage.getItem('accessToken'),
|
|
|
|
|
|
user: JSON.parse(localStorage.getItem('user') ?? 'null'),
|
|
|
|
|
|
login: (token, user) => {
|
|
|
|
|
|
localStorage.setItem('accessToken', token);
|
|
|
|
|
|
localStorage.setItem('user', JSON.stringify(user));
|
|
|
|
|
|
set({ accessToken: token, user });
|
|
|
|
|
|
},
|
|
|
|
|
|
logout: () => {
|
|
|
|
|
|
localStorage.removeItem('accessToken');
|
|
|
|
|
|
localStorage.removeItem('user');
|
|
|
|
|
|
set({ accessToken: null, user: null });
|
|
|
|
|
|
location.href = '/login';
|
|
|
|
|
|
},
|
|
|
|
|
|
}));
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## 7. 신입 개발자 5분 컷 패턴
|
|
|
|
|
|
|
|
|
|
|
|
```tsx
|
|
|
|
|
|
export default function AgentList() {
|
|
|
|
|
|
const { canCreate, canExport } = usePermission('ORG_AGENT');
|
|
|
|
|
|
const [param, setParam] = useSearchParam({ status: '' });
|
|
|
|
|
|
const { data, isLoading } = useQuery({
|
|
|
|
|
|
queryKey: ['agents', param],
|
|
|
|
|
|
queryFn: () => agentApi.list(param),
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<PageContainer title="설계사 관리">
|
|
|
|
|
|
<SearchForm conditions={[
|
|
|
|
|
|
{ type: 'text', name: 'searchKeyword', label: '설계사명' },
|
|
|
|
|
|
{ type: 'code', name: 'status', label: '상태', groupCode: 'AGENT_STATUS' },
|
|
|
|
|
|
]} onSearch={setParam} />
|
|
|
|
|
|
|
|
|
|
|
|
<Space style={{ marginBottom: 16, justifyContent: 'flex-end' }}>
|
|
|
|
|
|
<PermissionButton menuCode="ORG_AGENT" permCode="CREATE" type="primary"
|
|
|
|
|
|
onClick={() => navigate('/org/agents/new')}>등록</PermissionButton>
|
|
|
|
|
|
{canExport && <ExcelExportButton url="/api/agents/export" params={param}
|
|
|
|
|
|
fileName="설계사목록" showProgress />}
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
|
|
|
|
|
|
<DataTable
|
|
|
|
|
|
loading={isLoading}
|
|
|
|
|
|
dataSource={data?.list ?? []}
|
|
|
|
|
|
rowKey="agentId"
|
|
|
|
|
|
pagination={{ total: data?.total ?? 0, current: param.pageNum, pageSize: param.pageSize }}
|
|
|
|
|
|
columns={[
|
|
|
|
|
|
{ title: '설계사명', dataIndex: 'agentName' },
|
|
|
|
|
|
{ title: '소속', dataIndex: 'orgName' },
|
|
|
|
|
|
{ title: '직급', dataIndex: 'gradeName' },
|
|
|
|
|
|
{ title: '상태', dataIndex: 'status',
|
|
|
|
|
|
render: v => <CodeBadge groupCode="AGENT_STATUS" value={v} /> },
|
|
|
|
|
|
{ title: '계약수', dataIndex: 'contractCount', align: 'right' },
|
|
|
|
|
|
{ title: '수수료', dataIndex: 'totalAmt',
|
|
|
|
|
|
render: v => <MoneyText value={v} />, align: 'right' },
|
|
|
|
|
|
]}
|
|
|
|
|
|
onRow={(r) => ({ onClick: () => navigate(`/org/agents/${r.agentId}`) })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</PageContainer>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|