Backend (server/): - Fastify + Prisma + PostgreSQL 16 - JWT 인증 (bcrypt) + 카카오 OAuth (/auth/kakao — kapi.kakao.com 호출) - REST API: auth, users, family, policies, claims, score, notifications, diagnosis, consults - 실제 보험점수 알고리즘 (카테고리별 가중치·최소보장 기반) - Multipart 업로드 (영수증/진단서 → 디스크 persistence) - Swagger UI /docs Client: - api/client.ts + api/endpoints.ts (fetch 래퍼 + AsyncStorage 토큰) - 인증 스토어 (hydrate/login/register/kakao/logout) - 로그인/회원가입 화면 + 카카오 버튼 - 홈/내보험/가족/점수/청구 API 연동 (pull-to-refresh) - 보험 추가 모달 + 가족 구성원 추가 모달 - 로그인 전/후 스택 분기 (RootNavigator) Infra: - docker-compose.yml (로컬 Postgres+API) - server/Dockerfile (Prisma migrate deploy + node) - deploy/k8s/postgres.yaml (StatefulSet + 10Gi PVC) - deploy/k8s/api.yaml (Deployment + Ingress api.insurance.junggomoa.com) - CI workflow 확장 (web + api 동시 빌드·배포) - POSTGRES_PASSWORD / JWT_SECRET Gitea Secrets 추가 필요 - 반응형 웹 레이아웃 (max-width 480px 폰 프레임) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+54
-17
@@ -2,14 +2,14 @@ name: Build & Deploy
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
- main
|
||||
branches: [master, main]
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
REGISTRY: git.junggomoa.com
|
||||
IMAGE_NAME: chpark/insurance
|
||||
WEB_IMAGE: chpark/insurance
|
||||
API_IMAGE: chpark/insurance-api
|
||||
API_BASE_URL: https://api.insurance.junggomoa.com
|
||||
|
||||
jobs:
|
||||
build-and-deploy:
|
||||
@@ -31,16 +31,31 @@ jobs:
|
||||
username: ${{ secrets.REGISTRY_USER }}
|
||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||
|
||||
- name: Build and push image
|
||||
- name: Build & push WEB image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile
|
||||
build-args: |
|
||||
EXPO_PUBLIC_API_BASE=${{ env.API_BASE_URL }}
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.SHORT_SHA }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
|
||||
${{ env.REGISTRY }}/${{ env.WEB_IMAGE }}:latest
|
||||
${{ env.REGISTRY }}/${{ env.WEB_IMAGE }}:${{ env.SHORT_SHA }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.WEB_IMAGE }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.WEB_IMAGE }}:buildcache,mode=max
|
||||
|
||||
- name: Build & push API image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./server
|
||||
file: ./server/Dockerfile
|
||||
push: true
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.API_IMAGE }}:latest
|
||||
${{ env.REGISTRY }}/${{ env.API_IMAGE }}:${{ env.SHORT_SHA }}
|
||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.API_IMAGE }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.API_IMAGE }}:buildcache,mode=max
|
||||
|
||||
- name: Set up kubectl
|
||||
uses: azure/setup-kubectl@v4
|
||||
@@ -53,16 +68,40 @@ jobs:
|
||||
echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > $HOME/.kube/config
|
||||
chmod 600 $HOME/.kube/config
|
||||
|
||||
- name: Ensure namespace & registry secret
|
||||
- name: Ensure namespace, registry & DB secrets
|
||||
run: |
|
||||
kubectl apply -f deploy/k8s/namespace.yaml
|
||||
|
||||
kubectl -n insurance create secret docker-registry gitea-registry \
|
||||
--docker-server=${{ env.REGISTRY }} \
|
||||
--docker-username=${{ secrets.REGISTRY_USER }} \
|
||||
--docker-password=${{ secrets.REGISTRY_TOKEN }} \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
- name: Apply manifests
|
||||
kubectl -n insurance create secret generic postgres-credentials \
|
||||
--from-literal=username=insurance \
|
||||
--from-literal=password='${{ secrets.POSTGRES_PASSWORD }}' \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
kubectl -n insurance create secret generic api-secrets \
|
||||
--from-literal=jwtSecret='${{ secrets.JWT_SECRET }}' \
|
||||
--from-literal=databaseUrl="postgresql://insurance:${{ secrets.POSTGRES_PASSWORD }}@postgres:5432/insurance?schema=public" \
|
||||
--dry-run=client -o yaml | kubectl apply -f -
|
||||
|
||||
- name: Deploy Postgres
|
||||
run: kubectl apply -f deploy/k8s/postgres.yaml
|
||||
|
||||
- name: Wait for Postgres
|
||||
run: kubectl -n insurance rollout status statefulset/postgres --timeout=180s
|
||||
|
||||
- name: Deploy API
|
||||
run: |
|
||||
kubectl apply -f deploy/k8s/api.yaml
|
||||
kubectl -n insurance set image deployment/insurance-api \
|
||||
api=${{ env.REGISTRY }}/${{ env.API_IMAGE }}:${{ env.SHORT_SHA }}
|
||||
kubectl -n insurance rollout status deployment/insurance-api --timeout=240s
|
||||
|
||||
- name: Deploy Web
|
||||
run: |
|
||||
kubectl apply -f deploy/k8s/deployment.yaml
|
||||
kubectl apply -f deploy/k8s/service.yaml
|
||||
@@ -71,15 +110,13 @@ jobs:
|
||||
else
|
||||
kubectl apply -f deploy/k8s/ingress.yaml
|
||||
fi
|
||||
|
||||
- name: Update deployment image & restart
|
||||
run: |
|
||||
kubectl -n insurance set image deployment/insurance-web \
|
||||
web=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.SHORT_SHA }}
|
||||
web=${{ env.REGISTRY }}/${{ env.WEB_IMAGE }}:${{ env.SHORT_SHA }}
|
||||
kubectl -n insurance rollout status deployment/insurance-web --timeout=180s
|
||||
|
||||
- name: Show deployment info
|
||||
run: |
|
||||
kubectl -n insurance get deployment,svc,ingress
|
||||
kubectl -n insurance get deployment,statefulset,svc,ingress,pvc
|
||||
echo ""
|
||||
echo "🚀 Deployed: https://insurance.junggomoa.com"
|
||||
echo "🚀 Web: https://insurance.junggomoa.com"
|
||||
echo "🔌 API: https://api.insurance.junggomoa.com"
|
||||
|
||||
@@ -14,3 +14,4 @@ npm-debug.*
|
||||
.env
|
||||
.env.local
|
||||
*.log
|
||||
.claude/
|
||||
|
||||
@@ -1,16 +1,49 @@
|
||||
import React from 'react';
|
||||
import { View, StyleSheet, Platform } from 'react-native';
|
||||
import { StatusBar } from 'expo-status-bar';
|
||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||
import { NavigationContainer } from '@react-navigation/native';
|
||||
import RootNavigator from './src/navigation/RootNavigator';
|
||||
import ErrorBoundary from './src/components/ErrorBoundary';
|
||||
|
||||
export default function App() {
|
||||
const isWeb = Platform.OS === 'web';
|
||||
return (
|
||||
<SafeAreaProvider>
|
||||
<NavigationContainer>
|
||||
<StatusBar style="dark" />
|
||||
<RootNavigator />
|
||||
</NavigationContainer>
|
||||
</SafeAreaProvider>
|
||||
<ErrorBoundary>
|
||||
<SafeAreaProvider>
|
||||
<View style={[styles.outer, isWeb && styles.webOuter]}>
|
||||
<View style={[styles.inner, isWeb && styles.webInner]}>
|
||||
<NavigationContainer>
|
||||
<StatusBar style="dark" />
|
||||
<RootNavigator />
|
||||
</NavigationContainer>
|
||||
</View>
|
||||
</View>
|
||||
</SafeAreaProvider>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
outer: { flex: 1 },
|
||||
webOuter: {
|
||||
flex: 1,
|
||||
backgroundColor: '#E5E7EB',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
minHeight: '100%' as any,
|
||||
},
|
||||
inner: { flex: 1 },
|
||||
webInner: {
|
||||
width: '100%',
|
||||
maxWidth: 480,
|
||||
height: '100%' as any,
|
||||
minHeight: 800,
|
||||
backgroundColor: '#F9FAFB',
|
||||
shadowColor: '#000',
|
||||
shadowOffset: { width: 0, height: 4 },
|
||||
shadowOpacity: 0.15,
|
||||
shadowRadius: 20,
|
||||
elevation: 10,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,78 +1,40 @@
|
||||
# 🚀 배포 가이드
|
||||
|
||||
## 자동 배포 파이프라인
|
||||
# 🚀 배포 가이드 (Full Stack)
|
||||
|
||||
```
|
||||
git push → Gitea Actions → Docker 빌드 → Container Registry → Kubernetes rollout
|
||||
↓
|
||||
insurance.junggomoa.com
|
||||
git push → Gitea Actions
|
||||
├─ Web Docker build → insurance.junggomoa.com (nginx)
|
||||
├─ API Docker build → api.insurance.junggomoa.com (Fastify)
|
||||
└─ Postgres StatefulSet (10Gi PVC)
|
||||
```
|
||||
|
||||
---
|
||||
## ☑ 한 번만 설정 (Gitea Repo Secrets)
|
||||
|
||||
## 📋 한 번만 해야 하는 초기 설정
|
||||
[https://git.junggomoa.com/chpark/insurance/settings/actions/secrets](https://git.junggomoa.com/chpark/insurance/settings/actions/secrets)
|
||||
|
||||
### 1단계 — Gitea 측 설정 (웹 UI)
|
||||
|
||||
#### ① Gitea Actions Runner 활성화
|
||||
1. [https://git.junggomoa.com/chpark/insurance](https://git.junggomoa.com/chpark/insurance) → **Settings**
|
||||
2. 좌측 **Actions** 메뉴 → **Enable Actions** 체크
|
||||
3. 조직/인스턴스 관리자가 **Runner**를 하나 등록해둬야 합니다
|
||||
- Runner 없다면 관리자에게 요청
|
||||
- 또는 Kubernetes에 `act-runner` Helm 차트로 직접 설치 (아래 스크립트 참고)
|
||||
|
||||
#### ② Container Registry 접근 토큰 발급
|
||||
1. Gitea → 우측 상단 프로필 → **Settings** → **Applications**
|
||||
2. **Generate New Token** → 권한 `write:package`, `read:package` 체크
|
||||
3. 발급된 토큰 복사 (한 번만 보임)
|
||||
|
||||
#### ③ Repository Secrets 등록
|
||||
Repo → **Settings** → **Secrets and Variables** → **Actions** → **Add Secret**:
|
||||
|
||||
| Name | Value | 설명 |
|
||||
| Secret | 값 | 비고 |
|
||||
|---|---|---|
|
||||
| `REGISTRY_USER` | `chpark` | Gitea 사용자명 |
|
||||
| `REGISTRY_TOKEN` | (위에서 발급한 토큰) | Container Registry 인증 |
|
||||
| `KUBE_CONFIG` | (아래 2단계에서 생성) | base64 인코딩된 kubeconfig |
|
||||
| `INGRESS_MODE` | `ingress` 또는 `ingressroute` | Traefik 설치 방식 (아래 확인) |
|
||||
| `REGISTRY_TOKEN` | (Gitea → Settings → Applications → Generate Token, `write:package` 체크) | |
|
||||
| `KUBE_CONFIG` | 서버에서 생성한 base64 kubeconfig | 아래 스크립트 참고 |
|
||||
| `POSTGRES_PASSWORD` | 임의의 강한 비밀번호 (예: `openssl rand -hex 24`) | DB 비번 |
|
||||
| `JWT_SECRET` | 임의의 32자 이상 랜덤 문자열 (`openssl rand -hex 32`) | JWT 서명키 |
|
||||
| `INGRESS_MODE` | `ingress` 또는 `ingressroute` | Traefik 버전 |
|
||||
|
||||
---
|
||||
## 🔑 kubeconfig 생성 (서버에서 한 번만)
|
||||
|
||||
### 2단계 — Kubernetes 측 설정 (서버 SSH 1회)
|
||||
|
||||
SSH 접속:
|
||||
```bash
|
||||
ssh chpark@183.99.177.40
|
||||
```
|
||||
|
||||
#### ① Traefik 설치 방식 확인
|
||||
```bash
|
||||
kubectl api-resources | grep -i traefik
|
||||
```
|
||||
- 결과에 `ingressroutes.traefik.io` 나오면 → `INGRESS_MODE=ingressroute`
|
||||
- 아무것도 안 나오면 → `INGRESS_MODE=ingress` (표준 Ingress 사용)
|
||||
|
||||
#### ② CI용 kubeconfig 생성 (서비스 어카운트 방식, 권장)
|
||||
```bash
|
||||
# 네임스페이스·서비스어카운트 선생성
|
||||
kubectl create namespace insurance 2>/dev/null || true
|
||||
kubectl -n insurance create serviceaccount gitea-deployer
|
||||
kubectl create clusterrolebinding gitea-deployer \
|
||||
--clusterrole=cluster-admin \
|
||||
--serviceaccount=insurance:gitea-deployer
|
||||
|
||||
# 토큰 생성 (24시간 기본, 필요 시 --duration=8760h 로 1년)
|
||||
TOKEN=$(kubectl -n insurance create token gitea-deployer --duration=8760h)
|
||||
|
||||
# 현재 API 서버 주소
|
||||
SERVER=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')
|
||||
CA=$(kubectl -n insurance get secret \
|
||||
$(kubectl -n insurance get sa gitea-deployer -o jsonpath='{.secrets[0].name}' 2>/dev/null || echo "") \
|
||||
-o jsonpath='{.data.ca\.crt}' 2>/dev/null || \
|
||||
kubectl config view --minify --raw -o jsonpath='{.clusters[0].cluster.certificate-authority-data}')
|
||||
CA=$(kubectl config view --minify --raw -o jsonpath='{.clusters[0].cluster.certificate-authority-data}')
|
||||
|
||||
# kubeconfig 생성
|
||||
cat <<EOF > /tmp/gitea-kubeconfig
|
||||
cat > /tmp/gitea-kubeconfig <<EOF
|
||||
apiVersion: v1
|
||||
kind: Config
|
||||
clusters:
|
||||
@@ -93,99 +55,85 @@ users:
|
||||
token: ${TOKEN}
|
||||
EOF
|
||||
|
||||
# base64 인코딩 → Gitea secret에 넣을 값
|
||||
base64 -w0 /tmp/gitea-kubeconfig
|
||||
```
|
||||
출력된 긴 문자열을 복사해서 Gitea의 `KUBE_CONFIG` secret에 붙여넣으세요.
|
||||
|
||||
#### ③ DNS 확인
|
||||
`insurance.junggomoa.com` 이 k8s 클러스터의 Traefik LoadBalancer IP 로 등록돼 있어야 합니다.
|
||||
출력된 긴 문자열 → `KUBE_CONFIG` 시크릿에 붙여넣기.
|
||||
|
||||
## 🌐 DNS
|
||||
|
||||
A 레코드 2개 필요:
|
||||
- `insurance.junggomoa.com` → Traefik LoadBalancer IP
|
||||
- `api.insurance.junggomoa.com` → Traefik LoadBalancer IP
|
||||
|
||||
## 💻 로컬 개발 (docker-compose)
|
||||
|
||||
```bash
|
||||
dig insurance.junggomoa.com +short
|
||||
# 1. 서버 의존성 + DB 기동
|
||||
cd server
|
||||
npm install
|
||||
cd ..
|
||||
docker compose up -d postgres
|
||||
|
||||
# 2. Prisma 마이그레이션
|
||||
cd server
|
||||
cp .env.example .env
|
||||
npx prisma migrate dev --name init
|
||||
npm run dev # → http://localhost:4000 (Swagger: /docs)
|
||||
|
||||
# 3. 다른 터미널에서 모바일/웹 클라이언트
|
||||
cd ..
|
||||
npm run web # → http://localhost:8081
|
||||
# 브라우저에서 API 가 localhost:4000 에 붙도록 자동 기본값 동작
|
||||
```
|
||||
IP가 안 나오거나 잘못됐다면 DNS 관리자에게 추가 요청:
|
||||
- `insurance.junggomoa.com A <클러스터 LB IP>`
|
||||
|
||||
---
|
||||
## 🧪 API 스모크 테스트
|
||||
|
||||
## ✅ 이후부터 — 자동 배포
|
||||
|
||||
코드 수정 후:
|
||||
```bash
|
||||
git add .
|
||||
git commit -m "수정 내용"
|
||||
git push
|
||||
# 회원가입
|
||||
curl -X POST http://localhost:4000/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"test@test.com","password":"password123","name":"박철현","age":34,"gender":"MALE","job":"사무직"}'
|
||||
|
||||
# 로그인
|
||||
curl -X POST http://localhost:4000/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"test@test.com","password":"password123"}'
|
||||
|
||||
# 카카오 로그인 (카카오 SDK에서 받은 access_token 필요)
|
||||
curl -X POST http://localhost:4000/auth/kakao \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"accessToken":"YOUR_KAKAO_ACCESS_TOKEN"}'
|
||||
```
|
||||
|
||||
Gitea Actions 탭에서 진행 상황 확인:
|
||||
- [https://git.junggomoa.com/chpark/insurance/actions](https://git.junggomoa.com/chpark/insurance/actions)
|
||||
## 📦 카카오 로그인 연동
|
||||
|
||||
**약 3~5분 후** `https://insurance.junggomoa.com` 에 새 버전 반영됩니다.
|
||||
### 백엔드
|
||||
- `POST /auth/kakao` 엔드포인트가 `access_token`을 받아 `https://kapi.kakao.com/v2/user/me` 호출 → 프로필 매핑 → JWT 발급
|
||||
|
||||
---
|
||||
### 클라이언트
|
||||
현재 웹에서는 개발용 `window.prompt`로 토큰 붙여넣기 지원. 실제 운영에서는:
|
||||
- **Web**: Kakao JS SDK 로 `Kakao.Auth.login()` 호출 → `access_token` 획득 → `/auth/kakao`
|
||||
- **Native (iOS/Android)**: `@react-native-seoul/kakao-login` — `bare workflow`로 prebuild 후 연동
|
||||
|
||||
## 🧪 수동 1회 배포 (CI 세팅 전 테스트용)
|
||||
### 필요한 Kakao Developer 설정
|
||||
1. https://developers.kakao.com/ 앱 생성
|
||||
2. 플랫폼: Web (도메인 등록: `https://insurance.junggomoa.com`), Android/iOS 별도
|
||||
3. 카카오 로그인 → Redirect URI: `https://insurance.junggomoa.com/auth/kakao/callback`
|
||||
4. 동의항목: 닉네임(필수), 프로필 이미지(선택), 이메일(선택)
|
||||
|
||||
## 🛠 트러블슈팅
|
||||
|
||||
SSH 접속 후:
|
||||
```bash
|
||||
# 코드 받기
|
||||
git clone https://git.junggomoa.com/chpark/insurance.git
|
||||
cd insurance
|
||||
# Pod 로그
|
||||
kubectl -n insurance logs -l app.kubernetes.io/name=insurance-api --tail=100
|
||||
|
||||
# 도커 이미지 빌드
|
||||
docker build -t git.junggomoa.com/chpark/insurance:latest .
|
||||
# DB 접속
|
||||
kubectl -n insurance exec -it postgres-0 -- psql -U insurance
|
||||
|
||||
# 레지스트리 로그인 & push
|
||||
docker login git.junggomoa.com -u chpark -p <토큰>
|
||||
docker push git.junggomoa.com/chpark/insurance:latest
|
||||
# 마이그레이션 수동 실행
|
||||
kubectl -n insurance exec -it deploy/insurance-api -- npx prisma migrate deploy
|
||||
|
||||
# K8s 시크릿 & 매니페스트 적용
|
||||
kubectl apply -f deploy/k8s/namespace.yaml
|
||||
kubectl -n insurance create secret docker-registry gitea-registry \
|
||||
--docker-server=git.junggomoa.com \
|
||||
--docker-username=chpark \
|
||||
--docker-password=<토큰>
|
||||
kubectl apply -f deploy/k8s/deployment.yaml
|
||||
kubectl apply -f deploy/k8s/service.yaml
|
||||
kubectl apply -f deploy/k8s/ingress.yaml # 또는 ingressroute-traefik.yaml
|
||||
|
||||
# 상태 확인
|
||||
kubectl -n insurance get all
|
||||
kubectl -n insurance get ingress
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🔧 트러블슈팅
|
||||
|
||||
| 증상 | 원인 | 해결 |
|
||||
|---|---|---|
|
||||
| `ImagePullBackOff` | Registry 인증 실패 | `gitea-registry` secret 재생성 |
|
||||
| `404 page not found` (Traefik) | Ingress host 불일치 | `insurance.junggomoa.com` 철자 확인 |
|
||||
| TLS 인증서 없음 | cert-manager 미설정 | 관리자에게 와일드카드 인증서 요청 또는 cert-manager 설치 |
|
||||
| Actions 실패: `no runner` | Runner 미등록 | 조직에 act-runner 등록 필요 |
|
||||
|
||||
로그 확인:
|
||||
```bash
|
||||
kubectl -n insurance logs -l app.kubernetes.io/name=insurance-web --tail=100
|
||||
kubectl -n insurance describe deployment insurance-web
|
||||
kubectl -n insurance describe ingress insurance-web
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📁 배포 관련 파일
|
||||
|
||||
```
|
||||
Dockerfile # Multi-stage 빌드 (Expo → nginx)
|
||||
.dockerignore
|
||||
.gitea/workflows/deploy.yml # CI/CD 파이프라인
|
||||
deploy/
|
||||
├── nginx.conf # SPA fallback + gzip + health
|
||||
└── k8s/
|
||||
├── namespace.yaml
|
||||
├── deployment.yaml # 2 replicas, rolling update
|
||||
├── service.yaml # ClusterIP :80
|
||||
├── ingress.yaml # 표준 Ingress (Traefik ingressClass)
|
||||
└── ingressroute-traefik.yaml # Traefik CRD 버전 (선택)
|
||||
# 이미지 재배포
|
||||
kubectl -n insurance rollout restart deployment/insurance-api deployment/insurance-web
|
||||
```
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
ARG EXPO_PUBLIC_API_BASE=https://api.insurance.junggomoa.com
|
||||
ENV EXPO_PUBLIC_API_BASE=${EXPO_PUBLIC_API_BASE}
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --legacy-peer-deps --no-audit --no-fund
|
||||
|
||||
|
||||
@@ -9,7 +9,9 @@
|
||||
"resizeMode": "contain",
|
||||
"backgroundColor": "#3B82F6"
|
||||
},
|
||||
"assetBundlePatterns": ["**/*"],
|
||||
"assetBundlePatterns": [
|
||||
"**/*"
|
||||
],
|
||||
"ios": {
|
||||
"supportsTablet": true,
|
||||
"bundleIdentifier": "com.insurancecare.app",
|
||||
@@ -38,7 +40,8 @@
|
||||
"photosPermission": "보험금 청구 서류 첨부를 위해 사진 접근이 필요합니다."
|
||||
}
|
||||
],
|
||||
"expo-notifications"
|
||||
"expo-notifications",
|
||||
"expo-font"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
apiVersion: v1
|
||||
kind: PersistentVolumeClaim
|
||||
metadata:
|
||||
name: uploads
|
||||
namespace: insurance
|
||||
spec:
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
resources:
|
||||
requests:
|
||||
storage: 20Gi
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: insurance-api
|
||||
namespace: insurance
|
||||
labels:
|
||||
app.kubernetes.io/name: insurance-api
|
||||
spec:
|
||||
replicas: 2
|
||||
revisionHistoryLimit: 3
|
||||
strategy:
|
||||
type: RollingUpdate
|
||||
rollingUpdate:
|
||||
maxSurge: 1
|
||||
maxUnavailable: 0
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: insurance-api
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: insurance-api
|
||||
spec:
|
||||
imagePullSecrets:
|
||||
- name: gitea-registry
|
||||
containers:
|
||||
- name: api
|
||||
image: git.junggomoa.com/chpark/insurance-api:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 4000
|
||||
env:
|
||||
- name: PORT
|
||||
value: "4000"
|
||||
- name: HOST
|
||||
value: "0.0.0.0"
|
||||
- name: NODE_ENV
|
||||
value: production
|
||||
- name: UPLOAD_DIR
|
||||
value: /data/uploads
|
||||
- name: JWT_SECRET
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: api-secrets
|
||||
key: jwtSecret
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: api-secrets
|
||||
key: databaseUrl
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /health
|
||||
port: http
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 20
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 128Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
volumeMounts:
|
||||
- name: uploads
|
||||
mountPath: /data/uploads
|
||||
volumes:
|
||||
- name: uploads
|
||||
persistentVolumeClaim:
|
||||
claimName: uploads
|
||||
---
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: insurance-api
|
||||
namespace: insurance
|
||||
labels:
|
||||
app.kubernetes.io/name: insurance-api
|
||||
spec:
|
||||
type: ClusterIP
|
||||
selector:
|
||||
app.kubernetes.io/name: insurance-api
|
||||
ports:
|
||||
- name: http
|
||||
port: 4000
|
||||
targetPort: http
|
||||
---
|
||||
apiVersion: networking.k8s.io/v1
|
||||
kind: Ingress
|
||||
metadata:
|
||||
name: insurance-api
|
||||
namespace: insurance
|
||||
annotations:
|
||||
traefik.ingress.kubernetes.io/router.entrypoints: websecure
|
||||
traefik.ingress.kubernetes.io/router.tls: "true"
|
||||
spec:
|
||||
ingressClassName: traefik
|
||||
tls:
|
||||
- hosts:
|
||||
- api.insurance.junggomoa.com
|
||||
secretName: insurance-api-tls
|
||||
rules:
|
||||
- host: api.insurance.junggomoa.com
|
||||
http:
|
||||
paths:
|
||||
- path: /
|
||||
pathType: Prefix
|
||||
backend:
|
||||
service:
|
||||
name: insurance-api
|
||||
port:
|
||||
number: 4000
|
||||
@@ -0,0 +1,81 @@
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: postgres
|
||||
namespace: insurance
|
||||
labels:
|
||||
app.kubernetes.io/name: postgres
|
||||
spec:
|
||||
clusterIP: None
|
||||
selector:
|
||||
app.kubernetes.io/name: postgres
|
||||
ports:
|
||||
- name: postgres
|
||||
port: 5432
|
||||
targetPort: 5432
|
||||
---
|
||||
apiVersion: apps/v1
|
||||
kind: StatefulSet
|
||||
metadata:
|
||||
name: postgres
|
||||
namespace: insurance
|
||||
spec:
|
||||
serviceName: postgres
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels:
|
||||
app.kubernetes.io/name: postgres
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app.kubernetes.io/name: postgres
|
||||
spec:
|
||||
containers:
|
||||
- name: postgres
|
||||
image: postgres:16-alpine
|
||||
ports:
|
||||
- containerPort: 5432
|
||||
name: postgres
|
||||
env:
|
||||
- name: POSTGRES_USER
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: postgres-credentials
|
||||
key: username
|
||||
- name: POSTGRES_PASSWORD
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: postgres-credentials
|
||||
key: password
|
||||
- name: POSTGRES_DB
|
||||
value: insurance
|
||||
- name: PGDATA
|
||||
value: /var/lib/postgresql/data/pgdata
|
||||
volumeMounts:
|
||||
- name: data
|
||||
mountPath: /var/lib/postgresql/data
|
||||
readinessProbe:
|
||||
exec:
|
||||
command: ["pg_isready", "-U", "insurance"]
|
||||
initialDelaySeconds: 5
|
||||
periodSeconds: 10
|
||||
livenessProbe:
|
||||
exec:
|
||||
command: ["pg_isready", "-U", "insurance"]
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 20
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 1000m
|
||||
memory: 1Gi
|
||||
volumeClaimTemplates:
|
||||
- metadata:
|
||||
name: data
|
||||
spec:
|
||||
accessModes: ["ReadWriteOnce"]
|
||||
resources:
|
||||
requests:
|
||||
storage: 10Gi
|
||||
@@ -0,0 +1,41 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: insurance
|
||||
POSTGRES_PASSWORD: insurance_dev
|
||||
POSTGRES_DB: insurance
|
||||
ports:
|
||||
- "5432:5432"
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U insurance"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
|
||||
api:
|
||||
build:
|
||||
context: ./server
|
||||
dockerfile: Dockerfile
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DATABASE_URL: postgresql://insurance:insurance_dev@postgres:5432/insurance?schema=public
|
||||
JWT_SECRET: dev-change-me
|
||||
PORT: "4000"
|
||||
HOST: "0.0.0.0"
|
||||
NODE_ENV: development
|
||||
UPLOAD_DIR: /data/uploads
|
||||
ports:
|
||||
- "4000:4000"
|
||||
volumes:
|
||||
- uploads:/data/uploads
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
uploads:
|
||||
Generated
+873
-222
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -17,14 +17,14 @@
|
||||
"@react-navigation/native-stack": "^6.9.26",
|
||||
"expo": "~51.0.0",
|
||||
"expo-device": "~6.0.2",
|
||||
"expo-font": "^55.0.6",
|
||||
"expo-image-picker": "~15.0.5",
|
||||
"expo-font": "~12.0.10",
|
||||
"expo-image-picker": "~15.1.0",
|
||||
"expo-linear-gradient": "~13.0.2",
|
||||
"expo-notifications": "~0.28.0",
|
||||
"expo-status-bar": "~1.12.1",
|
||||
"react": "18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"react-native": "0.74.3",
|
||||
"react-native": "0.74.5",
|
||||
"react-native-chart-kit": "^6.12.0",
|
||||
"react-native-gesture-handler": "~2.16.1",
|
||||
"react-native-reanimated": "~3.10.1",
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
node_modules
|
||||
dist
|
||||
.env*
|
||||
*.log
|
||||
.git
|
||||
@@ -0,0 +1,6 @@
|
||||
DATABASE_URL=postgresql://insurance:insurance_dev@localhost:5432/insurance?schema=public
|
||||
JWT_SECRET=change-me-in-prod
|
||||
PORT=4000
|
||||
HOST=0.0.0.0
|
||||
NODE_ENV=development
|
||||
UPLOAD_DIR=./uploads
|
||||
@@ -0,0 +1,31 @@
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --no-audit --no-fund
|
||||
|
||||
COPY prisma ./prisma
|
||||
RUN npx prisma generate
|
||||
|
||||
COPY tsconfig.json ./
|
||||
COPY src ./src
|
||||
RUN npm run build
|
||||
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci --omit=dev --no-audit --no-fund
|
||||
|
||||
COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma
|
||||
COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma
|
||||
COPY --from=builder /app/prisma ./prisma
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
EXPOSE 4000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=15s --retries=3 \
|
||||
CMD wget -q --spider http://localhost:4000/health || exit 1
|
||||
|
||||
CMD ["sh", "-c", "npx prisma migrate deploy && node dist/main.js"]
|
||||
Generated
+2968
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "insurance-api",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"main": "dist/main.js",
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/main.ts",
|
||||
"build": "tsc -p tsconfig.json",
|
||||
"start": "node dist/main.js",
|
||||
"prisma:generate": "prisma generate",
|
||||
"prisma:migrate": "prisma migrate deploy",
|
||||
"prisma:migrate:dev": "prisma migrate dev",
|
||||
"prisma:seed": "tsx prisma/seed.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cors": "^9.0.1",
|
||||
"@fastify/jwt": "^8.0.1",
|
||||
"@fastify/multipart": "^8.3.0",
|
||||
"@fastify/swagger": "^8.15.0",
|
||||
"@fastify/swagger-ui": "^4.1.0",
|
||||
"@prisma/client": "^5.22.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"fastify": "^4.28.1",
|
||||
"fastify-plugin": "^4.5.1",
|
||||
"pino-pretty": "^11.2.2",
|
||||
"zod": "^3.23.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/node": "^20.14.11",
|
||||
"prisma": "^5.22.0",
|
||||
"tsx": "^4.19.2",
|
||||
"typescript": "^5.5.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum AuthProvider {
|
||||
EMAIL
|
||||
KAKAO
|
||||
APPLE
|
||||
NAVER
|
||||
GOOGLE
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String? @unique
|
||||
passwordHash String?
|
||||
name String
|
||||
phone String?
|
||||
provider AuthProvider @default(EMAIL)
|
||||
kakaoId String? @unique
|
||||
appleSub String? @unique
|
||||
naverId String? @unique
|
||||
googleId String? @unique
|
||||
profileImage String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
profile Profile?
|
||||
family FamilyMember[]
|
||||
policies Policy[]
|
||||
claims Claim[]
|
||||
notifications Notification[]
|
||||
diagnoses Diagnosis[]
|
||||
healthChecks HealthCheck[]
|
||||
consults Consult[]
|
||||
}
|
||||
|
||||
model Profile {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique
|
||||
age Int
|
||||
gender Gender
|
||||
job String?
|
||||
monthlyPremium Int @default(0)
|
||||
score Int @default(0)
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
}
|
||||
|
||||
enum Gender {
|
||||
MALE
|
||||
FEMALE
|
||||
}
|
||||
|
||||
enum Relation {
|
||||
SELF
|
||||
SPOUSE
|
||||
CHILD
|
||||
PARENT
|
||||
SIBLING
|
||||
}
|
||||
|
||||
model FamilyMember {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
relation Relation
|
||||
name String
|
||||
age Int
|
||||
gender Gender
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
policies Policy[]
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
enum PolicyType {
|
||||
SILSON
|
||||
CANCER
|
||||
LIFE
|
||||
ACCIDENT
|
||||
CHILD
|
||||
NURSING
|
||||
FEMALE
|
||||
DENTAL
|
||||
DRIVER
|
||||
CAR
|
||||
}
|
||||
|
||||
model Policy {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
familyMemberId String?
|
||||
name String
|
||||
insurer String
|
||||
type PolicyType
|
||||
monthlyPremium Int
|
||||
coverage BigInt
|
||||
joinDate DateTime
|
||||
maturityDate DateTime?
|
||||
renewalDate DateTime?
|
||||
silsonGen Int?
|
||||
isGroup Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
familyMember FamilyMember? @relation(fields: [familyMemberId], references: [id], onDelete: SetNull)
|
||||
|
||||
@@index([userId])
|
||||
@@index([familyMemberId])
|
||||
}
|
||||
|
||||
enum ClaimStatus {
|
||||
SUBMITTED
|
||||
REVIEWING
|
||||
ADDITIONAL_DOCS
|
||||
APPROVED
|
||||
PAID
|
||||
REJECTED
|
||||
}
|
||||
|
||||
model Claim {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
title String
|
||||
hospital String?
|
||||
visitDate DateTime?
|
||||
status ClaimStatus @default(SUBMITTED)
|
||||
amount Int?
|
||||
aiEstimated String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
attachments ClaimAttachment[]
|
||||
events ClaimEvent[]
|
||||
|
||||
@@index([userId, status])
|
||||
}
|
||||
|
||||
enum AttachmentType {
|
||||
RECEIPT
|
||||
DIAGNOSIS
|
||||
DETAIL
|
||||
OTHER
|
||||
}
|
||||
|
||||
model ClaimAttachment {
|
||||
id String @id @default(cuid())
|
||||
claimId String
|
||||
type AttachmentType
|
||||
objectKey String
|
||||
mimeType String?
|
||||
size Int?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
claim Claim @relation(fields: [claimId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([claimId])
|
||||
}
|
||||
|
||||
model ClaimEvent {
|
||||
id String @id @default(cuid())
|
||||
claimId String
|
||||
status ClaimStatus
|
||||
note String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
claim Claim @relation(fields: [claimId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([claimId])
|
||||
}
|
||||
|
||||
enum NotificationTone {
|
||||
INFO
|
||||
WARN
|
||||
DANGER
|
||||
}
|
||||
|
||||
model Notification {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
title String
|
||||
body String
|
||||
tone NotificationTone @default(INFO)
|
||||
scheduled DateTime
|
||||
readAt DateTime?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, scheduled])
|
||||
}
|
||||
|
||||
model Diagnosis {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
answers Json
|
||||
score Int
|
||||
breakdown Json
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
model HealthCheck {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
date DateTime
|
||||
metrics Json
|
||||
summary String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, date])
|
||||
}
|
||||
|
||||
enum ConsultMethod {
|
||||
KAKAO
|
||||
PHONE
|
||||
VISIT
|
||||
}
|
||||
|
||||
enum ConsultStatus {
|
||||
REQUESTED
|
||||
SCHEDULED
|
||||
DONE
|
||||
CANCELED
|
||||
}
|
||||
|
||||
model Consult {
|
||||
id String @id @default(cuid())
|
||||
userId String
|
||||
method ConsultMethod
|
||||
phone String?
|
||||
preferredAt DateTime?
|
||||
memo String?
|
||||
status ConsultStatus @default(REQUESTED)
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([userId, status])
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import Fastify from 'fastify';
|
||||
import cors from '@fastify/cors';
|
||||
import jwt from '@fastify/jwt';
|
||||
import multipart from '@fastify/multipart';
|
||||
import swagger from '@fastify/swagger';
|
||||
import swaggerUi from '@fastify/swagger-ui';
|
||||
|
||||
import { prismaPlugin } from './plugins/prisma';
|
||||
import { authPlugin } from './plugins/auth';
|
||||
import { registerRoutes } from './routes';
|
||||
|
||||
export async function buildApp() {
|
||||
const app = Fastify({
|
||||
logger: {
|
||||
transport: process.env.NODE_ENV !== 'production' ? { target: 'pino-pretty', options: { colorize: true } } : undefined,
|
||||
},
|
||||
});
|
||||
|
||||
await app.register(cors, {
|
||||
origin: (origin, cb) => cb(null, true),
|
||||
credentials: true,
|
||||
});
|
||||
|
||||
await app.register(jwt, {
|
||||
secret: process.env.JWT_SECRET ?? 'dev-secret-change-me-in-prod',
|
||||
sign: { expiresIn: '7d' },
|
||||
});
|
||||
|
||||
await app.register(multipart, {
|
||||
limits: { fileSize: 10 * 1024 * 1024 },
|
||||
});
|
||||
|
||||
await app.register(swagger, {
|
||||
openapi: {
|
||||
info: { title: 'Insurance API', version: '1.0.0' },
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
await app.register(swaggerUi, { routePrefix: '/docs' });
|
||||
|
||||
await app.register(prismaPlugin);
|
||||
await app.register(authPlugin);
|
||||
|
||||
app.get('/health', async () => ({ ok: true, ts: Date.now() }));
|
||||
|
||||
await registerRoutes(app);
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import 'dotenv/config';
|
||||
import { buildApp } from './app';
|
||||
|
||||
const PORT = Number(process.env.PORT ?? 4000);
|
||||
const HOST = process.env.HOST ?? '0.0.0.0';
|
||||
|
||||
async function main() {
|
||||
const app = await buildApp();
|
||||
try {
|
||||
await app.listen({ port: PORT, host: HOST });
|
||||
app.log.info(`API up on http://${HOST}:${PORT}`);
|
||||
} catch (err) {
|
||||
app.log.error(err);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,25 @@
|
||||
import fp from 'fastify-plugin';
|
||||
import type { FastifyReply, FastifyRequest } from 'fastify';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
authenticate: (req: FastifyRequest, reply: FastifyReply) => Promise<void>;
|
||||
}
|
||||
}
|
||||
|
||||
declare module '@fastify/jwt' {
|
||||
interface FastifyJWT {
|
||||
payload: { sub: string; email?: string };
|
||||
user: { sub: string; email?: string };
|
||||
}
|
||||
}
|
||||
|
||||
export const authPlugin = fp(async (app) => {
|
||||
app.decorate('authenticate', async function (req: FastifyRequest, reply: FastifyReply) {
|
||||
try {
|
||||
await req.jwtVerify();
|
||||
} catch (err) {
|
||||
reply.code(401).send({ message: '인증이 필요합니다' });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
import fp from 'fastify-plugin';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
declare module 'fastify' {
|
||||
interface FastifyInstance {
|
||||
prisma: PrismaClient;
|
||||
}
|
||||
}
|
||||
|
||||
export const prismaPlugin = fp(async (app) => {
|
||||
const prisma = new PrismaClient({
|
||||
log: process.env.NODE_ENV === 'production' ? ['error'] : ['warn', 'error'],
|
||||
});
|
||||
await prisma.$connect();
|
||||
app.decorate('prisma', prisma);
|
||||
app.addHook('onClose', async () => {
|
||||
await prisma.$disconnect();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import bcrypt from 'bcrypt';
|
||||
import { z } from 'zod';
|
||||
|
||||
const RegisterBody = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
name: z.string().min(1),
|
||||
phone: z.string().optional(),
|
||||
age: z.number().int().min(0).max(120),
|
||||
gender: z.enum(['MALE', 'FEMALE']),
|
||||
job: z.string().optional(),
|
||||
});
|
||||
|
||||
const LoginBody = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
});
|
||||
|
||||
const KakaoBody = z.object({
|
||||
accessToken: z.string().min(10),
|
||||
});
|
||||
|
||||
type KakaoMe = {
|
||||
id: number;
|
||||
kakao_account?: {
|
||||
email?: string;
|
||||
profile?: { nickname?: string; profile_image_url?: string };
|
||||
phone_number?: string;
|
||||
};
|
||||
properties?: { nickname?: string; profile_image?: string };
|
||||
};
|
||||
|
||||
async function fetchKakaoProfile(accessToken: string): Promise<KakaoMe> {
|
||||
const res = await fetch('https://kapi.kakao.com/v2/user/me', {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!res.ok) throw new Error(`Kakao profile fetch failed: ${res.status}`);
|
||||
return (await res.json()) as KakaoMe;
|
||||
}
|
||||
|
||||
export async function authRoutes(app: FastifyInstance) {
|
||||
app.post('/register', async (req, reply) => {
|
||||
const body = RegisterBody.parse(req.body);
|
||||
const exists = await app.prisma.user.findUnique({ where: { email: body.email } });
|
||||
if (exists) return reply.code(409).send({ message: '이미 가입된 이메일입니다' });
|
||||
|
||||
const passwordHash = await bcrypt.hash(body.password, 10);
|
||||
const user = await app.prisma.user.create({
|
||||
data: {
|
||||
email: body.email,
|
||||
passwordHash,
|
||||
name: body.name,
|
||||
phone: body.phone,
|
||||
provider: 'EMAIL',
|
||||
profile: {
|
||||
create: {
|
||||
age: body.age,
|
||||
gender: body.gender,
|
||||
job: body.job ?? '기타',
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { profile: true },
|
||||
});
|
||||
|
||||
const token = await reply.jwtSign({ sub: user.id, email: user.email ?? undefined });
|
||||
return { token, user: publicUser(user) };
|
||||
});
|
||||
|
||||
app.post('/login', async (req, reply) => {
|
||||
const body = LoginBody.parse(req.body);
|
||||
const user = await app.prisma.user.findUnique({
|
||||
where: { email: body.email },
|
||||
include: { profile: true },
|
||||
});
|
||||
if (!user || !user.passwordHash) return reply.code(401).send({ message: '이메일 또는 비밀번호가 틀렸습니다' });
|
||||
|
||||
const ok = await bcrypt.compare(body.password, user.passwordHash);
|
||||
if (!ok) return reply.code(401).send({ message: '이메일 또는 비밀번호가 틀렸습니다' });
|
||||
|
||||
const token = await reply.jwtSign({ sub: user.id, email: user.email ?? undefined });
|
||||
return { token, user: publicUser(user) };
|
||||
});
|
||||
|
||||
app.post('/kakao', async (req, reply) => {
|
||||
const body = KakaoBody.parse(req.body);
|
||||
let me: KakaoMe;
|
||||
try {
|
||||
me = await fetchKakaoProfile(body.accessToken);
|
||||
} catch (e) {
|
||||
return reply.code(401).send({ message: '카카오 인증 실패' });
|
||||
}
|
||||
|
||||
const kakaoId = String(me.id);
|
||||
const email = me.kakao_account?.email;
|
||||
const name = me.kakao_account?.profile?.nickname ?? me.properties?.nickname ?? '카카오사용자';
|
||||
const profileImage = me.kakao_account?.profile?.profile_image_url ?? me.properties?.profile_image;
|
||||
|
||||
let user = await app.prisma.user.findUnique({
|
||||
where: { kakaoId },
|
||||
include: { profile: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
user = await app.prisma.user.create({
|
||||
data: {
|
||||
kakaoId,
|
||||
email: email ?? null,
|
||||
name,
|
||||
phone: me.kakao_account?.phone_number,
|
||||
provider: 'KAKAO',
|
||||
profileImage,
|
||||
profile: {
|
||||
create: {
|
||||
age: 30,
|
||||
gender: 'MALE',
|
||||
job: '기타',
|
||||
},
|
||||
},
|
||||
},
|
||||
include: { profile: true },
|
||||
});
|
||||
}
|
||||
|
||||
const token = await reply.jwtSign({ sub: user.id, email: user.email ?? undefined });
|
||||
return { token, user: publicUser(user) };
|
||||
});
|
||||
|
||||
app.get('/me', { onRequest: [app.authenticate] }, async (req) => {
|
||||
const user = await app.prisma.user.findUnique({
|
||||
where: { id: req.user.sub },
|
||||
include: { profile: true },
|
||||
});
|
||||
if (!user) throw new Error('User not found');
|
||||
return publicUser(user);
|
||||
});
|
||||
}
|
||||
|
||||
function publicUser(u: any) {
|
||||
return {
|
||||
id: u.id,
|
||||
email: u.email,
|
||||
name: u.name,
|
||||
phone: u.phone,
|
||||
provider: u.provider,
|
||||
profileImage: u.profileImage,
|
||||
profile: u.profile
|
||||
? {
|
||||
age: u.profile.age,
|
||||
gender: u.profile.gender,
|
||||
job: u.profile.job,
|
||||
monthlyPremium: u.profile.monthlyPremium,
|
||||
score: u.profile.score,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
const CreateClaim = z.object({
|
||||
title: z.string().min(1),
|
||||
hospital: z.string().optional(),
|
||||
visitDate: z.string().optional(),
|
||||
});
|
||||
|
||||
const UPLOAD_DIR = process.env.UPLOAD_DIR ?? './uploads';
|
||||
|
||||
export async function claimRoutes(app: FastifyInstance) {
|
||||
app.addHook('onRequest', app.authenticate);
|
||||
|
||||
await fs.mkdir(UPLOAD_DIR, { recursive: true });
|
||||
|
||||
app.get('/', async (req) => {
|
||||
const claims = await app.prisma.claim.findMany({
|
||||
where: { userId: req.user.sub },
|
||||
include: { attachments: true, events: { orderBy: { createdAt: 'desc' } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return claims;
|
||||
});
|
||||
|
||||
app.post('/', async (req, reply) => {
|
||||
const body = CreateClaim.parse(req.body);
|
||||
const claim = await app.prisma.claim.create({
|
||||
data: {
|
||||
userId: req.user.sub,
|
||||
title: body.title,
|
||||
hospital: body.hospital,
|
||||
visitDate: body.visitDate ? new Date(body.visitDate) : null,
|
||||
status: 'SUBMITTED',
|
||||
events: {
|
||||
create: { status: 'SUBMITTED', note: '청구 접수' },
|
||||
},
|
||||
},
|
||||
include: { attachments: true, events: true },
|
||||
});
|
||||
reply.code(201);
|
||||
return claim;
|
||||
});
|
||||
|
||||
app.post('/:id/attachments', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const existing = await app.prisma.claim.findFirst({ where: { id, userId: req.user.sub } });
|
||||
if (!existing) return reply.code(404).send({ message: 'Not found' });
|
||||
|
||||
const file = await req.file();
|
||||
if (!file) return reply.code(400).send({ message: 'File required' });
|
||||
|
||||
const type = String((file.fields as any)?.type?.value ?? 'OTHER').toUpperCase();
|
||||
const ext = path.extname(file.filename) || '';
|
||||
const key = `${crypto.randomUUID()}${ext}`;
|
||||
const abs = path.join(UPLOAD_DIR, key);
|
||||
const buffer = await file.toBuffer();
|
||||
await fs.writeFile(abs, buffer);
|
||||
|
||||
const attachment = await app.prisma.claimAttachment.create({
|
||||
data: {
|
||||
claimId: id,
|
||||
type: (['RECEIPT', 'DIAGNOSIS', 'DETAIL', 'OTHER'].includes(type) ? type : 'OTHER') as any,
|
||||
objectKey: key,
|
||||
mimeType: file.mimetype,
|
||||
size: buffer.length,
|
||||
},
|
||||
});
|
||||
reply.code(201);
|
||||
return attachment;
|
||||
});
|
||||
|
||||
app.patch('/:id/status', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const { status, note, amount } = (req.body ?? {}) as { status?: string; note?: string; amount?: number };
|
||||
if (!status) return reply.code(400).send({ message: 'status required' });
|
||||
|
||||
const existing = await app.prisma.claim.findFirst({ where: { id, userId: req.user.sub } });
|
||||
if (!existing) return reply.code(404).send({ message: 'Not found' });
|
||||
|
||||
const updated = await app.prisma.claim.update({
|
||||
where: { id },
|
||||
data: {
|
||||
status: status as any,
|
||||
...(amount !== undefined && { amount }),
|
||||
events: { create: { status: status as any, note } },
|
||||
},
|
||||
include: { events: { orderBy: { createdAt: 'desc' } } },
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
|
||||
const CreateConsult = z.object({
|
||||
method: z.enum(['KAKAO', 'PHONE', 'VISIT']),
|
||||
phone: z.string().optional(),
|
||||
preferredAt: z.string().optional(),
|
||||
memo: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function consultRoutes(app: FastifyInstance) {
|
||||
app.addHook('onRequest', app.authenticate);
|
||||
|
||||
app.get('/', async (req) => {
|
||||
return app.prisma.consult.findMany({
|
||||
where: { userId: req.user.sub },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/', async (req, reply) => {
|
||||
const body = CreateConsult.parse(req.body);
|
||||
const c = await app.prisma.consult.create({
|
||||
data: {
|
||||
userId: req.user.sub,
|
||||
method: body.method,
|
||||
phone: body.phone,
|
||||
preferredAt: body.preferredAt ? new Date(body.preferredAt) : null,
|
||||
memo: body.memo,
|
||||
},
|
||||
});
|
||||
reply.code(201);
|
||||
return c;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
import { computeScore } from './score';
|
||||
|
||||
const DiagnosisBody = z.object({
|
||||
answers: z.record(z.string(), z.string()),
|
||||
});
|
||||
|
||||
export async function diagnosisRoutes(app: FastifyInstance) {
|
||||
app.addHook('onRequest', app.authenticate);
|
||||
|
||||
app.get('/', async (req) => {
|
||||
return app.prisma.diagnosis.findMany({
|
||||
where: { userId: req.user.sub },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/', async (req, reply) => {
|
||||
const body = DiagnosisBody.parse(req.body);
|
||||
const score = await computeScore(app, req.user.sub);
|
||||
const recommendations = recommend(body.answers, score.breakdown);
|
||||
|
||||
const saved = await app.prisma.diagnosis.create({
|
||||
data: {
|
||||
userId: req.user.sub,
|
||||
answers: body.answers,
|
||||
score: score.total,
|
||||
breakdown: { categories: score.breakdown, recommendations },
|
||||
},
|
||||
});
|
||||
reply.code(201);
|
||||
return saved;
|
||||
});
|
||||
}
|
||||
|
||||
function recommend(answers: Record<string, string>, breakdown: Array<{ label: string; status: string }>) {
|
||||
const recs: Array<{ name: string; reason: string; priority: '필수' | '권장' | '선택' }> = [];
|
||||
|
||||
const missing = breakdown.filter((b) => b.status === 'none' || b.status === 'bad');
|
||||
missing.forEach((m) => {
|
||||
recs.push({
|
||||
name: m.label,
|
||||
reason: `현재 ${m.status === 'none' ? '미가입' : '보장 부족'}`,
|
||||
priority: m.label === '실손보험' ? '필수' : '권장',
|
||||
});
|
||||
});
|
||||
|
||||
if (answers.family === '있음') {
|
||||
recs.push({ name: '암보험 진단비 강화', reason: '가족력 있음', priority: '필수' });
|
||||
}
|
||||
if (answers.smoke === '흡연') {
|
||||
recs.push({ name: '뇌혈관/심장 특약', reason: '흡연자 고위험군', priority: '권장' });
|
||||
}
|
||||
if (answers.hospital === '3회 이상') {
|
||||
recs.push({ name: '입원일당 특약', reason: '잦은 입원 이력', priority: '권장' });
|
||||
}
|
||||
|
||||
return recs.slice(0, 6);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
|
||||
const CreateFamily = z.object({
|
||||
relation: z.enum(['SELF', 'SPOUSE', 'CHILD', 'PARENT', 'SIBLING']),
|
||||
name: z.string().min(1),
|
||||
age: z.number().int().min(0).max(120),
|
||||
gender: z.enum(['MALE', 'FEMALE']),
|
||||
});
|
||||
|
||||
const UpdateFamily = CreateFamily.partial();
|
||||
|
||||
export async function familyRoutes(app: FastifyInstance) {
|
||||
app.addHook('onRequest', app.authenticate);
|
||||
|
||||
app.get('/', async (req) => {
|
||||
const members = await app.prisma.familyMember.findMany({
|
||||
where: { userId: req.user.sub },
|
||||
include: { policies: true },
|
||||
orderBy: { relation: 'asc' },
|
||||
});
|
||||
return members.map(sanitize);
|
||||
});
|
||||
|
||||
app.post('/', async (req, reply) => {
|
||||
const body = CreateFamily.parse(req.body);
|
||||
const created = await app.prisma.familyMember.create({
|
||||
data: { ...body, userId: req.user.sub },
|
||||
});
|
||||
reply.code(201);
|
||||
return created;
|
||||
});
|
||||
|
||||
app.patch('/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = UpdateFamily.parse(req.body);
|
||||
const existing = await app.prisma.familyMember.findFirst({ where: { id, userId: req.user.sub } });
|
||||
if (!existing) return reply.code(404).send({ message: 'Not found' });
|
||||
return app.prisma.familyMember.update({ where: { id }, data: body });
|
||||
});
|
||||
|
||||
app.delete('/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const existing = await app.prisma.familyMember.findFirst({ where: { id, userId: req.user.sub } });
|
||||
if (!existing) return reply.code(404).send({ message: 'Not found' });
|
||||
await app.prisma.familyMember.delete({ where: { id } });
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
function sanitize(m: any) {
|
||||
return {
|
||||
...m,
|
||||
policies: m.policies.map((p: any) => ({ ...p, coverage: Number(p.coverage) })),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { authRoutes } from './auth';
|
||||
import { userRoutes } from './users';
|
||||
import { familyRoutes } from './family';
|
||||
import { policyRoutes } from './policies';
|
||||
import { claimRoutes } from './claims';
|
||||
import { scoreRoutes } from './score';
|
||||
import { notificationRoutes } from './notifications';
|
||||
import { diagnosisRoutes } from './diagnosis';
|
||||
import { consultRoutes } from './consults';
|
||||
|
||||
export async function registerRoutes(app: FastifyInstance) {
|
||||
await app.register(authRoutes, { prefix: '/auth' });
|
||||
await app.register(userRoutes, { prefix: '/users' });
|
||||
await app.register(familyRoutes, { prefix: '/family' });
|
||||
await app.register(policyRoutes, { prefix: '/policies' });
|
||||
await app.register(claimRoutes, { prefix: '/claims' });
|
||||
await app.register(scoreRoutes, { prefix: '/score' });
|
||||
await app.register(notificationRoutes, { prefix: '/notifications' });
|
||||
await app.register(diagnosisRoutes, { prefix: '/diagnosis' });
|
||||
await app.register(consultRoutes, { prefix: '/consults' });
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
|
||||
const CreateNotification = z.object({
|
||||
title: z.string(),
|
||||
body: z.string(),
|
||||
tone: z.enum(['INFO', 'WARN', 'DANGER']).optional(),
|
||||
scheduled: z.string(),
|
||||
});
|
||||
|
||||
export async function notificationRoutes(app: FastifyInstance) {
|
||||
app.addHook('onRequest', app.authenticate);
|
||||
|
||||
app.get('/', async (req) => {
|
||||
return app.prisma.notification.findMany({
|
||||
where: { userId: req.user.sub },
|
||||
orderBy: { scheduled: 'asc' },
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/', async (req, reply) => {
|
||||
const body = CreateNotification.parse(req.body);
|
||||
const created = await app.prisma.notification.create({
|
||||
data: {
|
||||
userId: req.user.sub,
|
||||
title: body.title,
|
||||
body: body.body,
|
||||
tone: body.tone ?? 'INFO',
|
||||
scheduled: new Date(body.scheduled),
|
||||
},
|
||||
});
|
||||
reply.code(201);
|
||||
return created;
|
||||
});
|
||||
|
||||
app.patch('/:id/read', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const existing = await app.prisma.notification.findFirst({ where: { id, userId: req.user.sub } });
|
||||
if (!existing) return reply.code(404).send({ message: 'Not found' });
|
||||
return app.prisma.notification.update({ where: { id }, data: { readAt: new Date() } });
|
||||
});
|
||||
|
||||
app.get('/upcoming', async (req) => {
|
||||
const policies = await app.prisma.policy.findMany({
|
||||
where: { userId: req.user.sub, renewalDate: { not: null } },
|
||||
});
|
||||
const now = new Date();
|
||||
return policies
|
||||
.filter((p) => p.renewalDate && p.renewalDate > now)
|
||||
.map((p) => ({
|
||||
policyId: p.id,
|
||||
title: `${p.name} 갱신`,
|
||||
scheduled: p.renewalDate,
|
||||
days: Math.ceil(((p.renewalDate as Date).getTime() - now.getTime()) / (1000 * 60 * 60 * 24)),
|
||||
}))
|
||||
.sort((a, b) => (a.days ?? 0) - (b.days ?? 0));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
|
||||
const PolicyType = z.enum([
|
||||
'SILSON',
|
||||
'CANCER',
|
||||
'LIFE',
|
||||
'ACCIDENT',
|
||||
'CHILD',
|
||||
'NURSING',
|
||||
'FEMALE',
|
||||
'DENTAL',
|
||||
'DRIVER',
|
||||
'CAR',
|
||||
]);
|
||||
|
||||
const CreatePolicy = z.object({
|
||||
name: z.string().min(1),
|
||||
insurer: z.string().min(1),
|
||||
type: PolicyType,
|
||||
monthlyPremium: z.number().int().min(0),
|
||||
coverage: z.number().int().min(0),
|
||||
joinDate: z.string().datetime().or(z.string()),
|
||||
maturityDate: z.string().datetime().or(z.string()).optional(),
|
||||
renewalDate: z.string().datetime().or(z.string()).optional(),
|
||||
silsonGen: z.number().int().min(1).max(5).optional(),
|
||||
isGroup: z.boolean().optional(),
|
||||
familyMemberId: z.string().optional(),
|
||||
});
|
||||
|
||||
const UpdatePolicy = CreatePolicy.partial();
|
||||
|
||||
export async function policyRoutes(app: FastifyInstance) {
|
||||
app.addHook('onRequest', app.authenticate);
|
||||
|
||||
app.get('/', async (req) => {
|
||||
const { familyMemberId } = (req.query ?? {}) as { familyMemberId?: string };
|
||||
const policies = await app.prisma.policy.findMany({
|
||||
where: { userId: req.user.sub, ...(familyMemberId ? { familyMemberId } : {}) },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return policies.map(sanitize);
|
||||
});
|
||||
|
||||
app.post('/', async (req, reply) => {
|
||||
const body = CreatePolicy.parse(req.body);
|
||||
const created = await app.prisma.policy.create({
|
||||
data: {
|
||||
userId: req.user.sub,
|
||||
name: body.name,
|
||||
insurer: body.insurer,
|
||||
type: body.type,
|
||||
monthlyPremium: body.monthlyPremium,
|
||||
coverage: BigInt(body.coverage),
|
||||
joinDate: new Date(body.joinDate),
|
||||
maturityDate: body.maturityDate ? new Date(body.maturityDate) : null,
|
||||
renewalDate: body.renewalDate ? new Date(body.renewalDate) : null,
|
||||
silsonGen: body.silsonGen,
|
||||
isGroup: body.isGroup ?? false,
|
||||
familyMemberId: body.familyMemberId,
|
||||
},
|
||||
});
|
||||
|
||||
await recomputeProfilePremium(app, req.user.sub);
|
||||
|
||||
reply.code(201);
|
||||
return sanitize(created);
|
||||
});
|
||||
|
||||
app.patch('/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = UpdatePolicy.parse(req.body);
|
||||
const existing = await app.prisma.policy.findFirst({ where: { id, userId: req.user.sub } });
|
||||
if (!existing) return reply.code(404).send({ message: 'Not found' });
|
||||
|
||||
const updated = await app.prisma.policy.update({
|
||||
where: { id },
|
||||
data: {
|
||||
...body,
|
||||
...(body.coverage !== undefined && { coverage: BigInt(body.coverage) }),
|
||||
...(body.joinDate !== undefined && { joinDate: new Date(body.joinDate) }),
|
||||
...(body.maturityDate !== undefined && { maturityDate: body.maturityDate ? new Date(body.maturityDate) : null }),
|
||||
...(body.renewalDate !== undefined && { renewalDate: body.renewalDate ? new Date(body.renewalDate) : null }),
|
||||
},
|
||||
});
|
||||
|
||||
await recomputeProfilePremium(app, req.user.sub);
|
||||
|
||||
return sanitize(updated);
|
||||
});
|
||||
|
||||
app.delete('/:id', async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const existing = await app.prisma.policy.findFirst({ where: { id, userId: req.user.sub } });
|
||||
if (!existing) return reply.code(404).send({ message: 'Not found' });
|
||||
await app.prisma.policy.delete({ where: { id } });
|
||||
|
||||
await recomputeProfilePremium(app, req.user.sub);
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
|
||||
function sanitize(p: any) {
|
||||
return { ...p, coverage: Number(p.coverage) };
|
||||
}
|
||||
|
||||
async function recomputeProfilePremium(app: FastifyInstance, userId: string) {
|
||||
const own = await app.prisma.policy.findMany({ where: { userId, familyMemberId: null } });
|
||||
const total = own.reduce((a, p) => a + p.monthlyPremium, 0);
|
||||
await app.prisma.profile.update({ where: { userId }, data: { monthlyPremium: total } });
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
export async function scoreRoutes(app: FastifyInstance) {
|
||||
app.addHook('onRequest', app.authenticate);
|
||||
|
||||
app.get('/', async (req) => {
|
||||
return computeScore(app, req.user.sub);
|
||||
});
|
||||
|
||||
app.post('/recompute', async (req) => {
|
||||
const result = await computeScore(app, req.user.sub);
|
||||
await app.prisma.profile.update({
|
||||
where: { userId: req.user.sub },
|
||||
data: { score: result.total },
|
||||
});
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
type Category = '실손보험' | '암보험' | '상해보험' | '종신보험' | '간병보험' | '치아보험';
|
||||
type Status = 'good' | 'warn' | 'bad' | 'none';
|
||||
|
||||
const categoryMap: Record<string, Category> = {
|
||||
SILSON: '실손보험',
|
||||
CANCER: '암보험',
|
||||
ACCIDENT: '상해보험',
|
||||
LIFE: '종신보험',
|
||||
NURSING: '간병보험',
|
||||
DENTAL: '치아보험',
|
||||
};
|
||||
|
||||
const weights: Record<Category, number> = {
|
||||
실손보험: 25,
|
||||
암보험: 20,
|
||||
상해보험: 15,
|
||||
종신보험: 15,
|
||||
간병보험: 15,
|
||||
치아보험: 10,
|
||||
};
|
||||
|
||||
const minCoverageByCategory: Record<Category, number> = {
|
||||
실손보험: 10_000_000,
|
||||
암보험: 30_000_000,
|
||||
상해보험: 10_000_000,
|
||||
종신보험: 30_000_000,
|
||||
간병보험: 20_000_000,
|
||||
치아보험: 3_000_000,
|
||||
};
|
||||
|
||||
export async function computeScore(app: FastifyInstance, userId: string) {
|
||||
const profile = await app.prisma.profile.findUnique({ where: { userId } });
|
||||
const policies = await app.prisma.policy.findMany({ where: { userId, familyMemberId: null } });
|
||||
|
||||
const categories = Object.keys(weights) as Category[];
|
||||
const breakdown = categories.map((cat) => {
|
||||
const mine = policies.filter((p) => categoryMap[p.type] === cat);
|
||||
if (mine.length === 0) {
|
||||
return { label: cat, value: 0, status: 'none' as Status };
|
||||
}
|
||||
const maxCoverage = Math.max(...mine.map((p) => Number(p.coverage)));
|
||||
const minReq = minCoverageByCategory[cat];
|
||||
const ratio = Math.min(1.5, maxCoverage / minReq);
|
||||
const value = Math.round(Math.min(100, ratio * 85 + mine.length * 5));
|
||||
const status: Status = value >= 80 ? 'good' : value >= 60 ? 'warn' : 'bad';
|
||||
return { label: cat, value, status };
|
||||
});
|
||||
|
||||
const total = Math.round(
|
||||
breakdown.reduce((a, b) => a + (b.value * weights[b.label as Category]) / 100, 0)
|
||||
);
|
||||
|
||||
// 나이 기반 간병보험 필요성 가중치
|
||||
let adjustedTotal = total;
|
||||
if (profile && profile.age >= 50 && breakdown.find((b) => b.label === '간병보험')?.status === 'none') {
|
||||
adjustedTotal = Math.max(0, total - 10);
|
||||
}
|
||||
|
||||
return { total: Math.min(100, Math.max(0, adjustedTotal)), breakdown };
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { z } from 'zod';
|
||||
|
||||
const UpdateProfile = z.object({
|
||||
age: z.number().int().min(0).max(120).optional(),
|
||||
gender: z.enum(['MALE', 'FEMALE']).optional(),
|
||||
job: z.string().optional(),
|
||||
phone: z.string().optional(),
|
||||
name: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function userRoutes(app: FastifyInstance) {
|
||||
app.patch('/me', { onRequest: [app.authenticate] }, async (req) => {
|
||||
const body = UpdateProfile.parse(req.body);
|
||||
const userId = req.user.sub;
|
||||
|
||||
const { name, phone, ...profileFields } = body;
|
||||
|
||||
await app.prisma.$transaction(async (tx) => {
|
||||
if (name !== undefined || phone !== undefined) {
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: { ...(name !== undefined && { name }), ...(phone !== undefined && { phone }) },
|
||||
});
|
||||
}
|
||||
if (Object.keys(profileFields).length > 0) {
|
||||
await tx.profile.update({
|
||||
where: { userId },
|
||||
data: profileFields,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "commonjs",
|
||||
"lib": ["ES2022"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": false,
|
||||
"sourceMap": true,
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
const defaultBase =
|
||||
Platform.OS === 'web'
|
||||
? (typeof window !== 'undefined' && (window as any).__API_BASE__) || 'http://localhost:4000'
|
||||
: 'http://10.0.2.2:4000';
|
||||
|
||||
export const API_BASE = (process.env.EXPO_PUBLIC_API_BASE as string) || defaultBase;
|
||||
|
||||
const TOKEN_KEY = 'insurance.token';
|
||||
|
||||
let memoryToken: string | null = null;
|
||||
|
||||
export async function loadToken() {
|
||||
if (memoryToken) return memoryToken;
|
||||
try {
|
||||
memoryToken = await AsyncStorage.getItem(TOKEN_KEY);
|
||||
} catch {
|
||||
memoryToken = null;
|
||||
}
|
||||
return memoryToken;
|
||||
}
|
||||
export async function saveToken(t: string) {
|
||||
memoryToken = t;
|
||||
try {
|
||||
await AsyncStorage.setItem(TOKEN_KEY, t);
|
||||
} catch {}
|
||||
}
|
||||
export async function clearToken() {
|
||||
memoryToken = null;
|
||||
try {
|
||||
await AsyncStorage.removeItem(TOKEN_KEY);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(public status: number, public payload: any, message: string) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
type Options = {
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
|
||||
body?: any;
|
||||
query?: Record<string, any>;
|
||||
multipart?: boolean;
|
||||
skipAuth?: boolean;
|
||||
};
|
||||
|
||||
export async function api<T = any>(path: string, opts: Options = {}): Promise<T> {
|
||||
const { method = 'GET', body, query, multipart, skipAuth } = opts;
|
||||
|
||||
let url = `${API_BASE}${path}`;
|
||||
if (query) {
|
||||
const qs = new URLSearchParams();
|
||||
Object.entries(query).forEach(([k, v]) => v !== undefined && qs.append(k, String(v)));
|
||||
const q = qs.toString();
|
||||
if (q) url += `?${q}`;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
if (!multipart) headers['Content-Type'] = 'application/json';
|
||||
|
||||
if (!skipAuth) {
|
||||
const tok = await loadToken();
|
||||
if (tok) headers['Authorization'] = `Bearer ${tok}`;
|
||||
}
|
||||
|
||||
const res = await fetch(url, {
|
||||
method,
|
||||
headers,
|
||||
body: multipart ? body : body !== undefined ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
const payload = text ? safeParse(text) : null;
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = payload?.message ?? `HTTP ${res.status}`;
|
||||
throw new ApiError(res.status, payload, msg);
|
||||
}
|
||||
return payload as T;
|
||||
}
|
||||
|
||||
function safeParse(t: string) {
|
||||
try {
|
||||
return JSON.parse(t);
|
||||
} catch {
|
||||
return t;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { api } from './client';
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
email: string | null;
|
||||
name: string;
|
||||
phone: string | null;
|
||||
provider: 'EMAIL' | 'KAKAO' | 'APPLE' | 'NAVER' | 'GOOGLE';
|
||||
profileImage: string | null;
|
||||
profile: {
|
||||
age: number;
|
||||
gender: 'MALE' | 'FEMALE';
|
||||
job: string;
|
||||
monthlyPremium: number;
|
||||
score: number;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type AuthResponse = { token: string; user: User };
|
||||
|
||||
export const authApi = {
|
||||
register: (body: {
|
||||
email: string;
|
||||
password: string;
|
||||
name: string;
|
||||
age: number;
|
||||
gender: 'MALE' | 'FEMALE';
|
||||
job?: string;
|
||||
phone?: string;
|
||||
}) => api<AuthResponse>('/auth/register', { method: 'POST', body, skipAuth: true }),
|
||||
|
||||
login: (email: string, password: string) =>
|
||||
api<AuthResponse>('/auth/login', { method: 'POST', body: { email, password }, skipAuth: true }),
|
||||
|
||||
kakao: (accessToken: string) =>
|
||||
api<AuthResponse>('/auth/kakao', { method: 'POST', body: { accessToken }, skipAuth: true }),
|
||||
|
||||
me: () => api<User>('/auth/me'),
|
||||
};
|
||||
|
||||
export type Policy = {
|
||||
id: string;
|
||||
name: string;
|
||||
insurer: string;
|
||||
type: string;
|
||||
monthlyPremium: number;
|
||||
coverage: number;
|
||||
joinDate: string;
|
||||
maturityDate: string | null;
|
||||
renewalDate: string | null;
|
||||
silsonGen: number | null;
|
||||
isGroup: boolean;
|
||||
familyMemberId: string | null;
|
||||
};
|
||||
|
||||
export const policyApi = {
|
||||
list: () => api<Policy[]>('/policies'),
|
||||
create: (body: Partial<Policy>) => api<Policy>('/policies', { method: 'POST', body }),
|
||||
update: (id: string, body: Partial<Policy>) => api<Policy>(`/policies/${id}`, { method: 'PATCH', body }),
|
||||
remove: (id: string) => api(`/policies/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
export type FamilyMember = {
|
||||
id: string;
|
||||
relation: 'SELF' | 'SPOUSE' | 'CHILD' | 'PARENT' | 'SIBLING';
|
||||
name: string;
|
||||
age: number;
|
||||
gender: 'MALE' | 'FEMALE';
|
||||
policies: Policy[];
|
||||
};
|
||||
|
||||
export const familyApi = {
|
||||
list: () => api<FamilyMember[]>('/family'),
|
||||
create: (body: Omit<FamilyMember, 'id' | 'policies'>) => api<FamilyMember>('/family', { method: 'POST', body }),
|
||||
update: (id: string, body: Partial<Omit<FamilyMember, 'id' | 'policies'>>) =>
|
||||
api<FamilyMember>(`/family/${id}`, { method: 'PATCH', body }),
|
||||
remove: (id: string) => api(`/family/${id}`, { method: 'DELETE' }),
|
||||
};
|
||||
|
||||
export type Claim = {
|
||||
id: string;
|
||||
title: string;
|
||||
hospital: string | null;
|
||||
visitDate: string | null;
|
||||
status: 'SUBMITTED' | 'REVIEWING' | 'ADDITIONAL_DOCS' | 'APPROVED' | 'PAID' | 'REJECTED';
|
||||
amount: number | null;
|
||||
attachments: Array<{ id: string; type: string; objectKey: string }>;
|
||||
events: Array<{ id: string; status: string; note: string | null; createdAt: string }>;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export const claimApi = {
|
||||
list: () => api<Claim[]>('/claims'),
|
||||
create: (body: { title: string; hospital?: string; visitDate?: string }) =>
|
||||
api<Claim>('/claims', { method: 'POST', body }),
|
||||
uploadAttachment: async (claimId: string, fileUri: string, type: 'RECEIPT' | 'DIAGNOSIS' | 'DETAIL') => {
|
||||
const form = new FormData();
|
||||
form.append('type', type);
|
||||
// @ts-expect-error RN FormData file shape
|
||||
form.append('file', { uri: fileUri, name: fileUri.split('/').pop(), type: 'image/jpeg' });
|
||||
return api(`/claims/${claimId}/attachments`, { method: 'POST', body: form as any, multipart: true });
|
||||
},
|
||||
};
|
||||
|
||||
export const scoreApi = {
|
||||
get: () => api<{ total: number; breakdown: Array<{ label: string; value: number; status: string }> }>('/score'),
|
||||
recompute: () =>
|
||||
api<{ total: number; breakdown: Array<{ label: string; value: number; status: string }> }>('/score/recompute', {
|
||||
method: 'POST',
|
||||
}),
|
||||
};
|
||||
|
||||
export const notificationApi = {
|
||||
list: () => api<any[]>('/notifications'),
|
||||
upcoming: () => api<any[]>('/notifications/upcoming'),
|
||||
markRead: (id: string) => api(`/notifications/${id}/read`, { method: 'PATCH' }),
|
||||
};
|
||||
|
||||
export const diagnosisApi = {
|
||||
list: () => api<any[]>('/diagnosis'),
|
||||
submit: (answers: Record<string, string>) => api<any>('/diagnosis', { method: 'POST', body: { answers } }),
|
||||
};
|
||||
|
||||
export const consultApi = {
|
||||
list: () => api<any[]>('/consults'),
|
||||
create: (body: { method: 'KAKAO' | 'PHONE' | 'VISIT'; phone?: string; preferredAt?: string; memo?: string }) =>
|
||||
api<any>('/consults', { method: 'POST', body }),
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import { colors } from '@/theme/colors';
|
||||
import { typography } from '@/theme/typography';
|
||||
|
||||
type Props = {
|
||||
data: Array<{ label: string; value: number }>;
|
||||
unit?: string;
|
||||
barColor?: string;
|
||||
height?: number;
|
||||
};
|
||||
|
||||
export default function BarChartSimple({ data, unit = '', barColor = colors.primary, height = 180 }: Props) {
|
||||
const max = Math.max(...data.map((d) => d.value), 1);
|
||||
return (
|
||||
<View style={styles.wrap}>
|
||||
<View style={[styles.bars, { height }]}>
|
||||
{data.map((d, i) => {
|
||||
const pct = (d.value / max) * 100;
|
||||
return (
|
||||
<View key={i} style={styles.barCol}>
|
||||
<Text style={styles.value}>
|
||||
{d.value}
|
||||
{unit}
|
||||
</Text>
|
||||
<View style={styles.barTrack}>
|
||||
<View style={[styles.bar, { height: `${pct}%`, backgroundColor: barColor }]} />
|
||||
</View>
|
||||
<Text style={styles.label}>{d.label}</Text>
|
||||
</View>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
wrap: { width: '100%' },
|
||||
bars: { flexDirection: 'row', alignItems: 'flex-end', justifyContent: 'space-between' },
|
||||
barCol: { flex: 1, alignItems: 'center', justifyContent: 'flex-end', paddingHorizontal: 4 },
|
||||
barTrack: { width: '70%', height: '70%', justifyContent: 'flex-end', marginVertical: 4 },
|
||||
bar: { width: '100%', borderTopLeftRadius: 6, borderTopRightRadius: 6, minHeight: 4 },
|
||||
value: { ...typography.small, color: colors.textSecondary, fontWeight: '600' },
|
||||
label: { ...typography.small, color: colors.text, fontWeight: '600' },
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import React from 'react';
|
||||
import { View, Text, ScrollView } from 'react-native';
|
||||
|
||||
type Props = { children: React.ReactNode };
|
||||
type State = { error: Error | null };
|
||||
|
||||
export default class ErrorBoundary extends React.Component<Props, State> {
|
||||
state: State = { error: null };
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error };
|
||||
}
|
||||
componentDidCatch(error: Error, info: React.ErrorInfo) {
|
||||
console.error('[ErrorBoundary]', error, info);
|
||||
}
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<ScrollView style={{ flex: 1, backgroundColor: '#FEE2E2', padding: 20 }}>
|
||||
<Text style={{ fontSize: 20, fontWeight: '800', color: '#991B1B' }}>⚠️ 렌더링 오류</Text>
|
||||
<Text style={{ marginTop: 8, color: '#7F1D1D', fontWeight: '600' }}>{this.state.error.message}</Text>
|
||||
<Text style={{ marginTop: 12, color: '#7F1D1D', fontSize: 12 }}>{this.state.error.stack}</Text>
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
return this.props.children as any;
|
||||
}
|
||||
}
|
||||
@@ -1,39 +1,49 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import Svg, { Circle } from 'react-native-svg';
|
||||
import { colors } from '@/theme/colors';
|
||||
import { typography } from '@/theme/typography';
|
||||
|
||||
type Props = {
|
||||
value: number; // 0-100
|
||||
value: number;
|
||||
size?: number;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export default function ScoreGauge({ value, size = 180, label = '내 보험 점수' }: Props) {
|
||||
const stroke = 14;
|
||||
const radius = (size - stroke) / 2;
|
||||
const circ = 2 * Math.PI * radius;
|
||||
const pct = Math.max(0, Math.min(100, value));
|
||||
const dash = circ * (pct / 100);
|
||||
const color = pct >= 80 ? colors.success : pct >= 60 ? colors.accent : colors.danger;
|
||||
const stroke = 14;
|
||||
|
||||
return (
|
||||
<View style={{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Svg width={size} height={size}>
|
||||
<Circle cx={size / 2} cy={size / 2} r={radius} stroke={colors.surfaceAlt} strokeWidth={stroke} fill="none" />
|
||||
<Circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
stroke={color}
|
||||
strokeWidth={stroke}
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${dash}, ${circ}`}
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
/>
|
||||
</Svg>
|
||||
<View
|
||||
style={[
|
||||
styles.track,
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
borderWidth: stroke,
|
||||
borderColor: colors.surfaceAlt,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<View
|
||||
style={[
|
||||
styles.fill,
|
||||
{
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
borderWidth: stroke,
|
||||
borderTopColor: color,
|
||||
borderRightColor: pct > 25 ? color : colors.surfaceAlt,
|
||||
borderBottomColor: pct > 50 ? color : colors.surfaceAlt,
|
||||
borderLeftColor: pct > 75 ? color : colors.surfaceAlt,
|
||||
transform: [{ rotate: `${pct * 3.6 - 90}deg` }],
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<View style={styles.center}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<Text style={[styles.value, { color }]}>{pct}</Text>
|
||||
@@ -44,11 +54,9 @@ export default function ScoreGauge({ value, size = 180, label = '내 보험 점
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
center: {
|
||||
position: 'absolute',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
track: { position: 'absolute' },
|
||||
fill: { position: 'absolute' },
|
||||
center: { position: 'absolute', alignItems: 'center', justifyContent: 'center' },
|
||||
label: { ...typography.caption, color: colors.textSecondary },
|
||||
value: { fontSize: 48, fontWeight: '800', marginTop: 2 },
|
||||
unit: { ...typography.small, color: colors.textTertiary },
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import React from 'react';
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, ActivityIndicator } from 'react-native';
|
||||
import { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||
import BottomTabs from './BottomTabs';
|
||||
import LoginScreen from '@/screens/auth/LoginScreen';
|
||||
import RegisterScreen from '@/screens/auth/RegisterScreen';
|
||||
import DiagnosisScreen from '@/screens/DiagnosisScreen';
|
||||
import AnalysisScreen from '@/screens/AnalysisScreen';
|
||||
import ScoreScreen from '@/screens/ScoreScreen';
|
||||
@@ -15,8 +18,12 @@ import AIJudgeScreen from '@/screens/AIJudgeScreen';
|
||||
import PremiumDietScreen from '@/screens/PremiumDietScreen';
|
||||
import SilsonGenScreen from '@/screens/SilsonGenScreen';
|
||||
import NotificationScreen from '@/screens/NotificationScreen';
|
||||
import { useAuthStore } from '@/store/useAuthStore';
|
||||
import { colors } from '@/theme/colors';
|
||||
|
||||
export type RootStackParamList = {
|
||||
Login: undefined;
|
||||
Register: undefined;
|
||||
Tabs: undefined;
|
||||
Diagnosis: undefined;
|
||||
Analysis: undefined;
|
||||
@@ -37,23 +44,46 @@ export type RootStackParamList = {
|
||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||
|
||||
export default function RootNavigator() {
|
||||
const { user, loading, hydrate } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
hydrate();
|
||||
}, [hydrate]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.background }}>
|
||||
<ActivityIndicator size="large" color={colors.primary} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
||||
<Stack.Screen name="Tabs" component={BottomTabs} />
|
||||
<Stack.Screen name="Diagnosis" component={DiagnosisScreen} />
|
||||
<Stack.Screen name="Analysis" component={AnalysisScreen} />
|
||||
<Stack.Screen name="Score" component={ScoreScreen} />
|
||||
<Stack.Screen name="Consult" component={ConsultScreen} />
|
||||
<Stack.Screen name="HiddenMoney" component={HiddenMoneyScreen} />
|
||||
<Stack.Screen name="DiseaseCode" component={DiseaseCodeScreen} />
|
||||
<Stack.Screen name="Claim" component={ClaimScreen} />
|
||||
<Stack.Screen name="HealthCheck" component={HealthCheckScreen} />
|
||||
<Stack.Screen name="Family" component={FamilyScreen} />
|
||||
<Stack.Screen name="HospitalChecklist" component={HospitalChecklistScreen} />
|
||||
<Stack.Screen name="AIJudge" component={AIJudgeScreen} />
|
||||
<Stack.Screen name="PremiumDiet" component={PremiumDietScreen} />
|
||||
<Stack.Screen name="SilsonGen" component={SilsonGenScreen} />
|
||||
<Stack.Screen name="Notifications" component={NotificationScreen} />
|
||||
{user ? (
|
||||
<>
|
||||
<Stack.Screen name="Tabs" component={BottomTabs} />
|
||||
<Stack.Screen name="Diagnosis" component={DiagnosisScreen} />
|
||||
<Stack.Screen name="Analysis" component={AnalysisScreen} />
|
||||
<Stack.Screen name="Score" component={ScoreScreen} />
|
||||
<Stack.Screen name="Consult" component={ConsultScreen} />
|
||||
<Stack.Screen name="HiddenMoney" component={HiddenMoneyScreen} />
|
||||
<Stack.Screen name="DiseaseCode" component={DiseaseCodeScreen} />
|
||||
<Stack.Screen name="Claim" component={ClaimScreen} />
|
||||
<Stack.Screen name="HealthCheck" component={HealthCheckScreen} />
|
||||
<Stack.Screen name="Family" component={FamilyScreen} />
|
||||
<Stack.Screen name="HospitalChecklist" component={HospitalChecklistScreen} />
|
||||
<Stack.Screen name="AIJudge" component={AIJudgeScreen} />
|
||||
<Stack.Screen name="PremiumDiet" component={PremiumDietScreen} />
|
||||
<Stack.Screen name="SilsonGen" component={SilsonGenScreen} />
|
||||
<Stack.Screen name="Notifications" component={NotificationScreen} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Stack.Screen name="Login" component={LoginScreen} />
|
||||
<Stack.Screen name="Register" component={RegisterScreen} />
|
||||
</>
|
||||
)}
|
||||
</Stack.Navigator>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Dimensions } from 'react-native';
|
||||
import { BarChart } from 'react-native-chart-kit';
|
||||
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||
import ScreenContainer from '@/components/ScreenContainer';
|
||||
import Header from '@/components/Header';
|
||||
import Card from '@/components/Card';
|
||||
import Section from '@/components/Section';
|
||||
import Badge from '@/components/Badge';
|
||||
import BarChartSimple from '@/components/BarChartSimple';
|
||||
import { colors } from '@/theme/colors';
|
||||
import { radius, spacing, typography } from '@/theme/typography';
|
||||
|
||||
const screenWidth = Dimensions.get('window').width;
|
||||
|
||||
const data = {
|
||||
'20대': { prem: 18, must: ['실손', '상해'], rec: ['암 (가족력)'], avgCoverage: '2,000만원' },
|
||||
'30대': { prem: 28, must: ['실손', '암', '종신'], rec: ['여성특화', '치아'], avgCoverage: '5,000만원' },
|
||||
@@ -79,26 +77,11 @@ export default function AnalysisScreen() {
|
||||
|
||||
<Section title="연령별 평균 월 보험료">
|
||||
<Card padding="md">
|
||||
<BarChart
|
||||
data={{
|
||||
labels: ages as unknown as string[],
|
||||
datasets: [{ data: ages.map((a) => data[a].prem) }],
|
||||
}}
|
||||
width={screenWidth - spacing.lg * 2 - spacing.md * 2}
|
||||
height={220}
|
||||
yAxisLabel=""
|
||||
yAxisSuffix="만"
|
||||
fromZero
|
||||
chartConfig={{
|
||||
backgroundGradientFrom: '#FFF',
|
||||
backgroundGradientTo: '#FFF',
|
||||
decimalPlaces: 0,
|
||||
color: (o = 1) => `rgba(59, 130, 246, ${o})`,
|
||||
labelColor: (o = 1) => `rgba(107, 114, 128, ${o})`,
|
||||
propsForBackgroundLines: { stroke: colors.border, strokeDasharray: '3,3' },
|
||||
barPercentage: 0.6,
|
||||
}}
|
||||
style={{ borderRadius: radius.md, marginLeft: -10 }}
|
||||
<BarChartSimple
|
||||
data={ages.map((a) => ({ label: a, value: data[a].prem }))}
|
||||
unit="만"
|
||||
barColor={colors.primary}
|
||||
height={200}
|
||||
/>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
+106
-68
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, RefreshControl, ScrollView } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
@@ -9,86 +9,124 @@ import Card from '@/components/Card';
|
||||
import Section from '@/components/Section';
|
||||
import Badge from '@/components/Badge';
|
||||
import Button from '@/components/Button';
|
||||
import { useAppStore } from '@/store/useAppStore';
|
||||
import { useDataStore } from '@/store/useDataStore';
|
||||
import { colors } from '@/theme/colors';
|
||||
import { spacing, typography } from '@/theme/typography';
|
||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
const statusLabel: Record<string, string> = {
|
||||
SUBMITTED: '접수',
|
||||
REVIEWING: '심사',
|
||||
ADDITIONAL_DOCS: '서류보완',
|
||||
APPROVED: '승인',
|
||||
PAID: '지급완료',
|
||||
REJECTED: '거절',
|
||||
};
|
||||
|
||||
export default function ClaimHubScreen() {
|
||||
const nav = useNavigation<Nav>();
|
||||
const claims = useAppStore((s) => s.claims);
|
||||
const { claims, fetchClaims } = useDataStore();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchClaims();
|
||||
}, [fetchClaims]);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await fetchClaims();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenContainer>
|
||||
<ScreenContainer scroll={false}>
|
||||
<Header title="보험금" showBack={false} />
|
||||
|
||||
<View style={{ padding: spacing.lg }}>
|
||||
<Card>
|
||||
<Text style={styles.h1}>📸 보험금, 앱에서 바로 청구</Text>
|
||||
<Text style={styles.dim}>영수증과 진단서 사진만 있으면 끝</Text>
|
||||
<View style={{ marginTop: 16, gap: 8 }}>
|
||||
<Button title="보험금 청구하기" onPress={() => nav.navigate('Claim')} />
|
||||
<Button title="AI 보험금 판정 받기" variant="outline" onPress={() => nav.navigate('AIJudge')} />
|
||||
</View>
|
||||
</Card>
|
||||
</View>
|
||||
|
||||
<Section title="진행 상태">
|
||||
{claims.map((c) => (
|
||||
<Card key={c.id} style={{ marginBottom: 10 }}>
|
||||
<View style={{ flexDirection: 'row' }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Badge
|
||||
label={c.status}
|
||||
tone={
|
||||
c.status === '지급완료' ? 'success' : c.status === '심사' ? 'primary' : c.status === '서류보완' ? 'warning' : 'neutral'
|
||||
}
|
||||
/>
|
||||
<Text style={[typography.bodyBold as any, { marginTop: 6 }]}>{c.title}</Text>
|
||||
<Text style={styles.dim}>{c.date}</Text>
|
||||
</View>
|
||||
{c.amount ? <Text style={styles.amt}>{c.amount.toLocaleString()}원</Text> : null}
|
||||
</View>
|
||||
</Card>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title="보험금 관련 바로가기">
|
||||
<View style={{ gap: 8 }}>
|
||||
<Card onPress={() => nav.navigate('HiddenMoney')}>
|
||||
<View style={styles.row}>
|
||||
<Ionicons name="cash" size={22} color={colors.success} />
|
||||
<View style={{ flex: 1, marginLeft: 12 }}>
|
||||
<Text style={typography.bodyBold as any}>숨은 보험금 조회</Text>
|
||||
<Text style={styles.dim}>못 받은 보험금 찾기</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
|
||||
</View>
|
||||
</Card>
|
||||
<Card onPress={() => nav.navigate('DiseaseCode')}>
|
||||
<View style={styles.row}>
|
||||
<Ionicons name="search" size={22} color={colors.accent} />
|
||||
<View style={{ flex: 1, marginLeft: 12 }}>
|
||||
<Text style={typography.bodyBold as any}>질병코드로 보장 확인</Text>
|
||||
<Text style={styles.dim}>"내 질병이 보장되나요?"</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
|
||||
</View>
|
||||
</Card>
|
||||
<Card onPress={() => nav.navigate('HospitalChecklist')}>
|
||||
<View style={styles.row}>
|
||||
<Ionicons name="medkit" size={22} color="#14B8A6" />
|
||||
<View style={{ flex: 1, marginLeft: 12 }}>
|
||||
<Text style={typography.bodyBold as any}>병원 가기 전 체크리스트</Text>
|
||||
<Text style={styles.dim}>서류 빠뜨리면 청구 불가!</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
|
||||
<ScrollView
|
||||
contentContainerStyle={{ paddingBottom: 40 }}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
||||
>
|
||||
<View style={{ padding: spacing.lg }}>
|
||||
<Card>
|
||||
<Text style={styles.h1}>📸 보험금, 앱에서 바로 청구</Text>
|
||||
<Text style={styles.dim}>영수증과 진단서 사진만 있으면 끝</Text>
|
||||
<View style={{ marginTop: 16, gap: 8 }}>
|
||||
<Button title="보험금 청구하기" onPress={() => nav.navigate('Claim')} />
|
||||
<Button title="AI 보험금 판정 받기" variant="outline" onPress={() => nav.navigate('AIJudge')} />
|
||||
</View>
|
||||
</Card>
|
||||
</View>
|
||||
</Section>
|
||||
|
||||
<Section title="진행 상태">
|
||||
{claims.length === 0 && (
|
||||
<Card>
|
||||
<Text style={styles.dim}>진행 중인 청구 건이 없습니다.</Text>
|
||||
</Card>
|
||||
)}
|
||||
{claims.map((c) => {
|
||||
const tone =
|
||||
c.status === 'PAID' || c.status === 'APPROVED'
|
||||
? 'success'
|
||||
: c.status === 'ADDITIONAL_DOCS' || c.status === 'REJECTED'
|
||||
? 'warning'
|
||||
: 'primary';
|
||||
return (
|
||||
<Card key={c.id} style={{ marginBottom: 10 }}>
|
||||
<View style={{ flexDirection: 'row' }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Badge label={statusLabel[c.status] ?? c.status} tone={tone} />
|
||||
<Text style={[typography.bodyBold as any, { marginTop: 6 }]}>{c.title}</Text>
|
||||
<Text style={styles.dim}>
|
||||
{c.hospital ? `${c.hospital} · ` : ''}
|
||||
{new Date(c.createdAt).toLocaleDateString()}
|
||||
</Text>
|
||||
<Text style={{ ...typography.small, color: colors.textTertiary, marginTop: 2 } as any}>
|
||||
서류 {c.attachments.length}건
|
||||
</Text>
|
||||
</View>
|
||||
{c.amount ? <Text style={styles.amt}>{c.amount.toLocaleString()}원</Text> : null}
|
||||
</View>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
|
||||
<Section title="보험금 관련 바로가기">
|
||||
<View style={{ gap: 8 }}>
|
||||
<Card onPress={() => nav.navigate('HiddenMoney')}>
|
||||
<View style={styles.row}>
|
||||
<Ionicons name="cash" size={22} color={colors.success} />
|
||||
<View style={{ flex: 1, marginLeft: 12 }}>
|
||||
<Text style={typography.bodyBold as any}>숨은 보험금 조회</Text>
|
||||
<Text style={styles.dim}>못 받은 보험금 찾기</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
|
||||
</View>
|
||||
</Card>
|
||||
<Card onPress={() => nav.navigate('DiseaseCode')}>
|
||||
<View style={styles.row}>
|
||||
<Ionicons name="search" size={22} color={colors.accent} />
|
||||
<View style={{ flex: 1, marginLeft: 12 }}>
|
||||
<Text style={typography.bodyBold as any}>질병코드로 보장 확인</Text>
|
||||
<Text style={styles.dim}>"내 질병이 보장되나요?"</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
|
||||
</View>
|
||||
</Card>
|
||||
<Card onPress={() => nav.navigate('HospitalChecklist')}>
|
||||
<View style={styles.row}>
|
||||
<Ionicons name="medkit" size={22} color="#14B8A6" />
|
||||
<View style={{ flex: 1, marginLeft: 12 }}>
|
||||
<Text style={typography.bodyBold as any}>병원 가기 전 체크리스트</Text>
|
||||
<Text style={styles.dim}>서류 빠뜨리면 청구 불가!</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
|
||||
</View>
|
||||
</Card>
|
||||
</View>
|
||||
</Section>
|
||||
</ScrollView>
|
||||
</ScreenContainer>
|
||||
);
|
||||
}
|
||||
|
||||
+64
-115
@@ -1,5 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Image, Alert } from 'react-native';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Image, Alert, TextInput, ScrollView } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import ScreenContainer from '@/components/ScreenContainer';
|
||||
@@ -8,38 +9,34 @@ import Card from '@/components/Card';
|
||||
import Section from '@/components/Section';
|
||||
import Button from '@/components/Button';
|
||||
import Badge from '@/components/Badge';
|
||||
import { claimApi } from '@/api/endpoints';
|
||||
import { useDataStore } from '@/store/useDataStore';
|
||||
import { colors } from '@/theme/colors';
|
||||
import { radius, spacing, typography } from '@/theme/typography';
|
||||
|
||||
type DocType = 'receipt' | 'diagnosis' | 'detail';
|
||||
type DocType = 'RECEIPT' | 'DIAGNOSIS' | 'DETAIL';
|
||||
|
||||
const docLabels: Record<DocType, { title: string; desc: string; icon: keyof typeof Ionicons.glyphMap }> = {
|
||||
receipt: { title: '영수증', desc: '진료비 영수증 원본', icon: 'receipt' },
|
||||
diagnosis: { title: '진단서', desc: '병원 발급 진단서', icon: 'document-text' },
|
||||
detail: { title: '세부내역서', desc: '비급여 항목 포함', icon: 'list' },
|
||||
RECEIPT: { title: '영수증', desc: '진료비 영수증 원본', icon: 'receipt' },
|
||||
DIAGNOSIS: { title: '진단서', desc: '병원 발급 진단서', icon: 'document-text' },
|
||||
DETAIL: { title: '세부내역서', desc: '비급여 항목 포함', icon: 'list' },
|
||||
};
|
||||
|
||||
const steps = [
|
||||
{ step: 1, label: '서류 준비', desc: '영수증/진단서 촬영' },
|
||||
{ step: 2, label: '정보 입력', desc: '병원명·진료일자' },
|
||||
{ step: 3, label: '자동 전송', desc: '보험사로 즉시 전달' },
|
||||
{ step: 4, label: '진행 상태', desc: '실시간 확인' },
|
||||
];
|
||||
|
||||
export default function ClaimScreen() {
|
||||
const nav = useNavigation<any>();
|
||||
const fetchClaims = useDataStore((s) => s.fetchClaims);
|
||||
const [docs, setDocs] = useState<Record<DocType, string | null>>({
|
||||
receipt: null,
|
||||
diagnosis: null,
|
||||
detail: null,
|
||||
RECEIPT: null,
|
||||
DIAGNOSIS: null,
|
||||
DETAIL: null,
|
||||
});
|
||||
const [title, setTitle] = useState('');
|
||||
const [hospital, setHospital] = useState('');
|
||||
const [visitDate, setVisitDate] = useState('');
|
||||
const [visitDate, setVisitDate] = useState(new Date().toISOString().slice(0, 10));
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const pick = async (type: DocType) => {
|
||||
const res = await ImagePicker.launchImageLibraryAsync({
|
||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
||||
quality: 0.7,
|
||||
});
|
||||
const res = await ImagePicker.launchImageLibraryAsync({ quality: 0.7 });
|
||||
if (!res.canceled && res.assets[0]) {
|
||||
setDocs({ ...docs, [type]: res.assets[0].uri });
|
||||
}
|
||||
@@ -47,59 +44,55 @@ export default function ClaimScreen() {
|
||||
|
||||
const capture = async (type: DocType) => {
|
||||
const perm = await ImagePicker.requestCameraPermissionsAsync();
|
||||
if (!perm.granted) {
|
||||
Alert.alert('카메라 권한이 필요합니다.');
|
||||
return;
|
||||
}
|
||||
if (!perm.granted) return Alert.alert('카메라 권한이 필요합니다.');
|
||||
const res = await ImagePicker.launchCameraAsync({ quality: 0.7 });
|
||||
if (!res.canceled && res.assets[0]) {
|
||||
setDocs({ ...docs, [type]: res.assets[0].uri });
|
||||
}
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
if (!docs.receipt) {
|
||||
Alert.alert('영수증은 필수입니다.');
|
||||
return;
|
||||
const submit = async () => {
|
||||
if (!title) return Alert.alert('청구 제목을 입력하세요');
|
||||
if (!docs.RECEIPT) return Alert.alert('영수증은 필수입니다');
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const claim = await claimApi.create({ title, hospital: hospital || undefined, visitDate });
|
||||
const uploads: Promise<any>[] = [];
|
||||
(Object.keys(docs) as DocType[]).forEach((t) => {
|
||||
const uri = docs[t];
|
||||
if (uri) uploads.push(claimApi.uploadAttachment(claim.id, uri, t));
|
||||
});
|
||||
await Promise.all(uploads);
|
||||
await fetchClaims();
|
||||
Alert.alert('청구 접수 완료', '보험사 심사 후 영업일 3~7일 내 지급됩니다.');
|
||||
nav.goBack();
|
||||
} catch (e: any) {
|
||||
Alert.alert('접수 실패', e?.message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
Alert.alert('청구 접수 완료', '보험사에서 심사 후 3~7영업일 내 지급됩니다.');
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenContainer>
|
||||
<ScreenContainer scroll={false}>
|
||||
<Header title="보험금 청구" />
|
||||
<View style={{ padding: spacing.lg }}>
|
||||
<ScrollView contentContainerStyle={{ padding: spacing.lg, paddingBottom: 40 }}>
|
||||
<Card>
|
||||
<Text style={typography.bodyBold as any}>📸 앱에서 바로 청구</Text>
|
||||
<Text style={typography.bodyBold as any}>📸 서류 업로드</Text>
|
||||
<View style={{ marginTop: 12, gap: 10 }}>
|
||||
{steps.map((s) => (
|
||||
<View key={s.step} style={styles.stepRow}>
|
||||
<View style={styles.stepCircle}>
|
||||
<Text style={styles.stepNum}>{s.step}</Text>
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={typography.bodyBold as any}>{s.label}</Text>
|
||||
<Text style={{ ...typography.caption, color: colors.textSecondary } as any}>{s.desc}</Text>
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Section title="서류 업로드">
|
||||
{(Object.keys(docLabels) as DocType[]).map((t) => {
|
||||
const info = docLabels[t];
|
||||
const uri = docs[t];
|
||||
return (
|
||||
<Card key={t} style={{ marginBottom: 10 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
{(Object.keys(docLabels) as DocType[]).map((t) => {
|
||||
const info = docLabels[t];
|
||||
const uri = docs[t];
|
||||
return (
|
||||
<View key={t} style={styles.docRow}>
|
||||
<View style={styles.docIcon}>
|
||||
<Ionicons name={info.icon} size={22} color={colors.primary} />
|
||||
</View>
|
||||
<View style={{ flex: 1, marginLeft: 12 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<Text style={typography.bodyBold as any}>{info.title}</Text>
|
||||
{t === 'receipt' && <Badge label="필수" tone="danger" style={{ marginLeft: 6 }} />}
|
||||
{t === 'RECEIPT' && <Badge label="필수" tone="danger" style={{ marginLeft: 6 }} />}
|
||||
</View>
|
||||
<Text style={{ ...typography.caption, color: colors.textSecondary } as any}>{info.desc}</Text>
|
||||
</View>
|
||||
@@ -118,80 +111,36 @@ export default function ClaimScreen() {
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Section title="청구 정보">
|
||||
<Card>
|
||||
<Text style={styles.label}>병원명</Text>
|
||||
<Text style={styles.inputView}>{hospital || '병원명 입력'}</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 6, flexWrap: 'wrap', marginTop: 4 }}>
|
||||
{['강남세브란스', '서울아산병원', '삼성의료원', '고려대병원'].map((h) => (
|
||||
<TouchableOpacity key={h} style={styles.pill} onPress={() => setHospital(h)}>
|
||||
<Text style={styles.pillText}>{h}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
<Text style={styles.label}>청구 제목 *</Text>
|
||||
<TextInput style={styles.input} placeholder="예) 발목 염좌 정형외과" value={title} onChangeText={setTitle} placeholderTextColor={colors.textTertiary} />
|
||||
<Text style={[styles.label, { marginTop: 12 }]}>병원명</Text>
|
||||
<TextInput style={styles.input} placeholder="예) 강남세브란스" value={hospital} onChangeText={setHospital} placeholderTextColor={colors.textTertiary} />
|
||||
<Text style={[styles.label, { marginTop: 12 }]}>진료일자</Text>
|
||||
<Text style={styles.inputView}>{visitDate || '2026-04-22'}</Text>
|
||||
<TextInput style={styles.input} placeholder="YYYY-MM-DD" value={visitDate} onChangeText={setVisitDate} placeholderTextColor={colors.textTertiary} />
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<View style={{ paddingTop: 16, gap: 8 }}>
|
||||
<Button title="보험금 청구하기" size="lg" onPress={submit} />
|
||||
<Button title="AI 판정으로 예상 금액 확인" variant="outline" onPress={() => {}} />
|
||||
<View style={{ marginTop: 16, gap: 8 }}>
|
||||
<Button title="보험금 청구하기" size="lg" onPress={submit} loading={submitting} />
|
||||
<Button title="AI 판정으로 예상 금액 확인" variant="outline" onPress={() => nav.navigate('AIJudge')} />
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ScreenContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
stepRow: { flexDirection: 'row', alignItems: 'center' },
|
||||
stepCircle: {
|
||||
width: 32,
|
||||
height: 32,
|
||||
borderRadius: 16,
|
||||
backgroundColor: colors.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
marginRight: 12,
|
||||
},
|
||||
stepNum: { color: '#FFF', fontWeight: '700' },
|
||||
docIcon: {
|
||||
width: 44,
|
||||
height: 44,
|
||||
borderRadius: 22,
|
||||
backgroundColor: colors.primaryLight,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
docRow: { flexDirection: 'row', alignItems: 'center' },
|
||||
docIcon: { width: 44, height: 44, borderRadius: 22, backgroundColor: colors.primaryLight, alignItems: 'center', justifyContent: 'center' },
|
||||
thumb: { width: 50, height: 50, borderRadius: 8 },
|
||||
smallBtn: {
|
||||
width: 38,
|
||||
height: 38,
|
||||
borderRadius: 19,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
smallBtn: { width: 38, height: 38, borderRadius: 19, borderWidth: 1, borderColor: colors.primary, alignItems: 'center', justifyContent: 'center' },
|
||||
label: { ...typography.caption, color: colors.textSecondary, fontWeight: '600' },
|
||||
inputView: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: radius.md,
|
||||
padding: 14,
|
||||
marginTop: 6,
|
||||
color: colors.text,
|
||||
},
|
||||
pill: {
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 6,
|
||||
borderRadius: radius.pill,
|
||||
backgroundColor: colors.surfaceAlt,
|
||||
},
|
||||
pillText: { ...typography.small, color: colors.text },
|
||||
input: { borderWidth: 1, borderColor: colors.border, borderRadius: radius.md, padding: 12, marginTop: 6, color: colors.text, backgroundColor: colors.surface },
|
||||
});
|
||||
|
||||
+187
-91
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, RefreshControl, TextInput, TouchableOpacity, Alert, Modal } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import ScreenContainer from '@/components/ScreenContainer';
|
||||
import Header from '@/components/Header';
|
||||
@@ -8,116 +8,204 @@ import Section from '@/components/Section';
|
||||
import Badge from '@/components/Badge';
|
||||
import ProgressBar from '@/components/ProgressBar';
|
||||
import Button from '@/components/Button';
|
||||
import { useAppStore, FamilyMember } from '@/store/useAppStore';
|
||||
import { useDataStore } from '@/store/useDataStore';
|
||||
import { familyApi, type FamilyMember } from '@/api/endpoints';
|
||||
import { colors } from '@/theme/colors';
|
||||
import { radius, spacing, typography } from '@/theme/typography';
|
||||
|
||||
const relationIcon: Record<FamilyMember['relation'], keyof typeof Ionicons.glyphMap> = {
|
||||
본인: 'person',
|
||||
배우자: 'heart',
|
||||
자녀: 'happy',
|
||||
부모: 'people',
|
||||
형제: 'people-outline',
|
||||
SELF: 'person',
|
||||
SPOUSE: 'heart',
|
||||
CHILD: 'happy',
|
||||
PARENT: 'people',
|
||||
SIBLING: 'people-outline',
|
||||
};
|
||||
|
||||
const essentialMap: Record<string, string[]> = {
|
||||
본인: ['실손', '암', '상해'],
|
||||
배우자: ['실손', '암', '여성'],
|
||||
자녀: ['어린이'],
|
||||
부모: ['실손', '간병'],
|
||||
const relationLabel: Record<FamilyMember['relation'], string> = {
|
||||
SELF: '본인',
|
||||
SPOUSE: '배우자',
|
||||
CHILD: '자녀',
|
||||
PARENT: '부모',
|
||||
SIBLING: '형제',
|
||||
};
|
||||
|
||||
const essentialMap: Partial<Record<FamilyMember['relation'], string[]>> = {
|
||||
SELF: ['SILSON', 'CANCER', 'ACCIDENT'],
|
||||
SPOUSE: ['SILSON', 'CANCER', 'FEMALE'],
|
||||
CHILD: ['CHILD'],
|
||||
PARENT: ['SILSON', 'NURSING'],
|
||||
};
|
||||
|
||||
export default function FamilyScreen() {
|
||||
const family = useAppStore((s) => s.family);
|
||||
const { family, fetchFamily } = useDataStore();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchFamily();
|
||||
}, [fetchFamily]);
|
||||
|
||||
const analyze = (m: FamilyMember) => {
|
||||
const required = essentialMap[m.relation] ?? [];
|
||||
const have = new Set(m.policies.map((p) => p.type));
|
||||
const missing = required.filter((r) => !have.has(r as any));
|
||||
const covered = required.length - missing.length;
|
||||
const score = required.length === 0 ? 100 : Math.round((covered / required.length) * 100);
|
||||
return { required, missing, covered, score };
|
||||
const missing = required.filter((r) => !have.has(r));
|
||||
const score = required.length === 0 ? 100 : Math.round(((required.length - missing.length) / required.length) * 100);
|
||||
return { required, missing, score };
|
||||
};
|
||||
|
||||
const avgScore = Math.round(family.reduce((a, m) => a + analyze(m).score, 0) / family.length);
|
||||
const avgScore = family.length > 0 ? Math.round(family.reduce((a, m) => a + analyze(m).score, 0) / family.length) : 0;
|
||||
|
||||
return (
|
||||
<ScreenContainer>
|
||||
<Header title="가족 보험" />
|
||||
<View style={{ padding: spacing.lg }}>
|
||||
<Card>
|
||||
<Text style={typography.caption as any}>👨👩👧 우리 가족 총 보장 점수</Text>
|
||||
<Text style={{ ...typography.h1 as any, color: colors.primary, marginTop: 6 }}>{avgScore}점</Text>
|
||||
<View style={{ height: 10 }} />
|
||||
<ProgressBar value={avgScore} />
|
||||
<Text style={{ ...typography.caption, color: colors.textSecondary, marginTop: 8 } as any}>
|
||||
가족 {family.length}명 평균 기준
|
||||
</Text>
|
||||
</Card>
|
||||
|
||||
<Section title="가족 구성원">
|
||||
{family.map((m) => {
|
||||
const a = analyze(m);
|
||||
return (
|
||||
<Card key={m.id} style={{ marginBottom: 10 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<View style={styles.avatar}>
|
||||
<Ionicons name={relationIcon[m.relation]} size={26} color={colors.primary} />
|
||||
</View>
|
||||
<View style={{ flex: 1, marginLeft: 12 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<Text style={typography.bodyBold as any}>{m.name}</Text>
|
||||
<Badge label={m.relation} tone="primary" style={{ marginLeft: 6 }} />
|
||||
</View>
|
||||
<Text style={{ ...typography.caption, color: colors.textSecondary } as any}>
|
||||
{m.age}세 · {m.gender}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={{ ...typography.h3 as any, color: scoreColor(a.score) }}>{a.score}점</Text>
|
||||
</View>
|
||||
|
||||
<View style={styles.sep} />
|
||||
<View style={{ gap: 6 }}>
|
||||
{m.policies.map((p) => (
|
||||
<View key={p.id} style={styles.policyRow}>
|
||||
<Ionicons name="checkmark-circle" size={16} color={colors.success} />
|
||||
<Text style={{ ...typography.body, marginLeft: 6, flex: 1 } as any} numberOfLines={1}>
|
||||
{p.type} · {p.name}
|
||||
</Text>
|
||||
<Text style={{ ...typography.small, color: colors.textSecondary } as any}>
|
||||
{(p.coverage / 10000).toLocaleString()}만
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
{a.missing.map((miss) => (
|
||||
<View key={miss} style={styles.policyRow}>
|
||||
<Ionicons name="close-circle" size={16} color={colors.danger} />
|
||||
<Text style={{ ...typography.body, marginLeft: 6, color: colors.danger, flex: 1 } as any}>
|
||||
{miss} 미가입 ⚠️
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
|
||||
<Section title="💡 가족 보험 TIP">
|
||||
<>
|
||||
<ScreenContainer scroll={false}>
|
||||
<Header title="가족 보험" />
|
||||
<ScrollView
|
||||
contentContainerStyle={{ padding: spacing.lg, paddingBottom: 40 }}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={async () => { setRefreshing(true); await fetchFamily(); setRefreshing(false); }} />}
|
||||
>
|
||||
<Card>
|
||||
<Text style={typography.body as any}>
|
||||
• 자녀는 어릴수록 어린이보험 가입 유리{'\n'}
|
||||
• 65세 이상은 유병자/간편 실손 검토{'\n'}
|
||||
• 부부 동시 가입 시 할인 혜택 있는 상품 다수
|
||||
<Text style={typography.caption as any}>👨👩👧 우리 가족 총 보장 점수</Text>
|
||||
<Text style={{ ...typography.h1 as any, color: colors.primary, marginTop: 6 }}>{avgScore}점</Text>
|
||||
<View style={{ height: 10 }} />
|
||||
<ProgressBar value={avgScore} />
|
||||
<Text style={{ ...typography.caption, color: colors.textSecondary, marginTop: 8 } as any}>
|
||||
가족 {family.length}명 평균 기준
|
||||
</Text>
|
||||
</Card>
|
||||
</Section>
|
||||
|
||||
<View style={{ paddingTop: 16 }}>
|
||||
<Button title="가족 구성원 추가" variant="outline" />
|
||||
</View>
|
||||
</View>
|
||||
</ScreenContainer>
|
||||
<Section
|
||||
title="가족 구성원"
|
||||
right={
|
||||
<TouchableOpacity onPress={() => setModalOpen(true)}>
|
||||
<Text style={styles.add}>+ 추가</Text>
|
||||
</TouchableOpacity>
|
||||
}
|
||||
>
|
||||
{family.length === 0 && (
|
||||
<Card>
|
||||
<Text style={styles.dim}>등록된 가족 구성원이 없습니다.</Text>
|
||||
</Card>
|
||||
)}
|
||||
{family.map((m) => {
|
||||
const a = analyze(m);
|
||||
return (
|
||||
<Card key={m.id} style={{ marginBottom: 10 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<View style={styles.avatar}>
|
||||
<Ionicons name={relationIcon[m.relation]} size={26} color={colors.primary} />
|
||||
</View>
|
||||
<View style={{ flex: 1, marginLeft: 12 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<Text style={typography.bodyBold as any}>{m.name}</Text>
|
||||
<Badge label={relationLabel[m.relation]} tone="primary" style={{ marginLeft: 6 }} />
|
||||
</View>
|
||||
<Text style={{ ...typography.caption, color: colors.textSecondary } as any}>
|
||||
{m.age}세 · {m.gender === 'MALE' ? '남' : '여'}
|
||||
</Text>
|
||||
</View>
|
||||
<Text style={{ ...typography.h3 as any, color: scoreColor(a.score) }}>{a.score}점</Text>
|
||||
<TouchableOpacity
|
||||
style={{ marginLeft: 10 }}
|
||||
onPress={() => {
|
||||
Alert.alert('삭제', `${m.name} 구성원을 삭제할까요?`, [
|
||||
{ text: '취소', style: 'cancel' },
|
||||
{ text: '삭제', style: 'destructive', onPress: async () => { await familyApi.remove(m.id); fetchFamily(); } },
|
||||
]);
|
||||
}}
|
||||
>
|
||||
<Ionicons name="trash-outline" size={18} color={colors.textTertiary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={styles.sep} />
|
||||
<View style={{ gap: 6 }}>
|
||||
{m.policies.length === 0 && (
|
||||
<Text style={styles.dim}>보험 없음</Text>
|
||||
)}
|
||||
{m.policies.map((p) => (
|
||||
<View key={p.id} style={styles.policyRow}>
|
||||
<Ionicons name="checkmark-circle" size={16} color={colors.success} />
|
||||
<Text style={{ ...typography.body, marginLeft: 6, flex: 1 } as any} numberOfLines={1}>
|
||||
{p.name}
|
||||
</Text>
|
||||
<Text style={{ ...typography.small, color: colors.textSecondary } as any}>
|
||||
{Math.round(p.coverage / 10000).toLocaleString()}만
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
{a.missing.map((miss) => (
|
||||
<View key={miss} style={styles.policyRow}>
|
||||
<Ionicons name="close-circle" size={16} color={colors.danger} />
|
||||
<Text style={{ ...typography.body, marginLeft: 6, color: colors.danger, flex: 1 } as any}>
|
||||
{miss} 미가입 ⚠️
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Section>
|
||||
</ScrollView>
|
||||
</ScreenContainer>
|
||||
<AddFamilyModal open={modalOpen} onClose={() => setModalOpen(false)} onCreated={fetchFamily} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AddFamilyModal({ open, onClose, onCreated }: { open: boolean; onClose: () => void; onCreated: () => void }) {
|
||||
const [relation, setRelation] = useState<FamilyMember['relation']>('SPOUSE');
|
||||
const [name, setName] = useState('');
|
||||
const [age, setAge] = useState('');
|
||||
const [gender, setGender] = useState<'MALE' | 'FEMALE'>('MALE');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!name || !age) return Alert.alert('필수 입력');
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await familyApi.create({ relation, name, age: parseInt(age, 10), gender });
|
||||
onCreated();
|
||||
onClose();
|
||||
setName('');
|
||||
setAge('');
|
||||
} catch (e: any) {
|
||||
Alert.alert('등록 실패', e?.message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={open} animationType="slide" onRequestClose={onClose}>
|
||||
<ScreenContainer scroll={false}>
|
||||
<Header title="가족 추가" showBack={false} right={<TouchableOpacity onPress={onClose}><Ionicons name="close" size={24} color={colors.text} /></TouchableOpacity>} />
|
||||
<ScrollView contentContainerStyle={{ padding: spacing.lg }}>
|
||||
<Text style={styles.label}>관계</Text>
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>
|
||||
{(Object.entries(relationLabel) as Array<[FamilyMember['relation'], string]>).map(([v, l]) => (
|
||||
<TouchableOpacity key={v} style={[styles.pill, relation === v && styles.pillActive]} onPress={() => setRelation(v)}>
|
||||
<Text style={[styles.pillText, relation === v && styles.pillTextActive]}>{l}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
<Text style={[styles.label, { marginTop: 12 }]}>이름 *</Text>
|
||||
<TextInput style={styles.input} value={name} onChangeText={setName} placeholderTextColor={colors.textTertiary} />
|
||||
<Text style={[styles.label, { marginTop: 12 }]}>나이 *</Text>
|
||||
<TextInput style={styles.input} value={age} onChangeText={setAge} keyboardType="number-pad" placeholderTextColor={colors.textTertiary} />
|
||||
<Text style={[styles.label, { marginTop: 12 }]}>성별</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 6 }}>
|
||||
{(['MALE', 'FEMALE'] as const).map((g) => (
|
||||
<TouchableOpacity key={g} style={[styles.pill, gender === g && styles.pillActive]} onPress={() => setGender(g)}>
|
||||
<Text style={[styles.pillText, gender === g && styles.pillTextActive]}>{g === 'MALE' ? '남성' : '여성'}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
<View style={{ height: 24 }} />
|
||||
<Button title="등록" size="lg" onPress={onSubmit} loading={submitting} />
|
||||
</ScrollView>
|
||||
</ScreenContainer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,4 +224,12 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
sep: { height: 1, backgroundColor: colors.border, marginVertical: 12 },
|
||||
policyRow: { flexDirection: 'row', alignItems: 'center' },
|
||||
add: { color: colors.primary, fontWeight: '700' },
|
||||
dim: { ...typography.caption, color: colors.textSecondary },
|
||||
label: { ...typography.caption, color: colors.textSecondary, fontWeight: '600', marginBottom: 6 },
|
||||
input: { borderWidth: 1, borderColor: colors.border, borderRadius: radius.md, padding: 12, color: colors.text, backgroundColor: colors.surface },
|
||||
pill: { paddingHorizontal: 14, paddingVertical: 8, borderRadius: radius.pill, backgroundColor: colors.surfaceAlt, borderWidth: 1, borderColor: colors.border },
|
||||
pillActive: { backgroundColor: colors.primary, borderColor: colors.primary },
|
||||
pillText: { color: colors.text, fontWeight: '500' },
|
||||
pillTextActive: { color: '#FFF', fontWeight: '700' },
|
||||
});
|
||||
|
||||
+57
-20
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-native';
|
||||
import React, { useEffect } from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, RefreshControl } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
@@ -12,21 +12,42 @@ import ProgressBar from '@/components/ProgressBar';
|
||||
import Badge from '@/components/Badge';
|
||||
import { colors } from '@/theme/colors';
|
||||
import { radius, shadow, spacing, typography } from '@/theme/typography';
|
||||
import { useAppStore } from '@/store/useAppStore';
|
||||
import { useAuthStore } from '@/store/useAuthStore';
|
||||
import { useDataStore } from '@/store/useDataStore';
|
||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
export default function HomeScreen() {
|
||||
const nav = useNavigation<Nav>();
|
||||
const profile = useAppStore((s) => s.profile);
|
||||
const score = useAppStore((s) => s.score);
|
||||
const hiddenMoney = useAppStore((s) => s.hiddenMoney);
|
||||
const notifications = useAppStore((s) => s.notifications);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const { score, notifications, upcoming, fetchAll } = useDataStore();
|
||||
const [refreshing, setRefreshing] = React.useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAll();
|
||||
}, [fetchAll]);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await fetchAll();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const totalScore = score?.total ?? user?.profile?.score ?? 0;
|
||||
const upcomingList = [...(upcoming ?? [])].slice(0, 3);
|
||||
const recentNotis = [...(notifications ?? [])].slice(0, 3);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.safe} edges={['top']}>
|
||||
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 32 }}>
|
||||
<ScrollView
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={{ paddingBottom: 32 }}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
||||
>
|
||||
<View style={styles.topBar}>
|
||||
<View>
|
||||
<Text style={styles.brand}>보험케어</Text>
|
||||
@@ -49,16 +70,16 @@ export default function HomeScreen() {
|
||||
end={{ x: 1, y: 1 }}
|
||||
style={styles.hero}
|
||||
>
|
||||
<Text style={styles.heroHello}>{profile.name}님, 안녕하세요 👋</Text>
|
||||
<Text style={styles.heroHello}>{user?.name ?? '사용자'}님, 안녕하세요 👋</Text>
|
||||
<View style={styles.heroRow}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.heroLabel}>내 보험 점수</Text>
|
||||
<Text style={styles.heroScore}>
|
||||
{score.total}
|
||||
{totalScore}
|
||||
<Text style={styles.heroScoreUnit}> /100</Text>
|
||||
</Text>
|
||||
<View style={{ height: 10 }} />
|
||||
<ProgressBar value={score.total} color="#FFF" track="rgba(255,255,255,0.3)" height={6} />
|
||||
<ProgressBar value={totalScore} color="#FFF" track="rgba(255,255,255,0.3)" height={6} />
|
||||
</View>
|
||||
<View style={styles.heroBadge}>
|
||||
<Ionicons name="shield-checkmark" size={36} color="#FFF" />
|
||||
@@ -92,7 +113,7 @@ export default function HomeScreen() {
|
||||
<View style={styles.grid}>
|
||||
<IconTile icon="stats-chart" label="점수" color="#8B5CF6" bg="#EDE9FE" onPress={() => nav.navigate('Score')} />
|
||||
<IconTile icon="people" label="연령별 분석" color="#EC4899" bg="#FCE7F3" onPress={() => nav.navigate('Analysis')} />
|
||||
<IconTile icon="cash" label="숨은보험금" color="#10B981" bg={colors.secondaryLight} badge="47만" onPress={() => nav.navigate('HiddenMoney')} />
|
||||
<IconTile icon="cash" label="숨은보험금" color="#10B981" bg={colors.secondaryLight} onPress={() => nav.navigate('HiddenMoney')} />
|
||||
<IconTile icon="search" label="질병코드" color="#F59E0B" bg={colors.accentLight} onPress={() => nav.navigate('DiseaseCode')} />
|
||||
<IconTile icon="receipt" label="보험금 청구" color={colors.primary} bg={colors.primaryLight} onPress={() => nav.navigate('Claim')} />
|
||||
<IconTile icon="fitness" label="건강검진 분석" color="#EF4444" bg={colors.dangerLight} onPress={() => nav.navigate('HealthCheck')} />
|
||||
@@ -110,11 +131,9 @@ export default function HomeScreen() {
|
||||
<Card onPress={() => nav.navigate('HiddenMoney')}>
|
||||
<View style={styles.rowBetween}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Badge label="못 받은 보험금 발견" tone="success" />
|
||||
<Text style={[typography.h2 as any, { marginTop: 8, color: colors.success }]}>
|
||||
{(hiddenMoney.unclaimed + hiddenMoney.dormant).toLocaleString()}원
|
||||
</Text>
|
||||
<Text style={styles.dim}>미청구 {hiddenMoney.unclaimed.toLocaleString()}원 + 휴면 {hiddenMoney.dormant.toLocaleString()}원</Text>
|
||||
<Badge label="못 받은 보험금 조회 가능" tone="success" />
|
||||
<Text style={[typography.h2 as any, { marginTop: 8, color: colors.success }]}>조회하기</Text>
|
||||
<Text style={styles.dim}>보험개발원 API 통합 조회</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={22} color={colors.textTertiary} />
|
||||
</View>
|
||||
@@ -122,13 +141,31 @@ export default function HomeScreen() {
|
||||
</Section>
|
||||
|
||||
<Section title="🔔 다가오는 알림">
|
||||
{notifications.map((n) => (
|
||||
{upcomingList.length === 0 && recentNotis.length === 0 ? (
|
||||
<Card>
|
||||
<Text style={styles.dim}>다가오는 만기·갱신 알림이 없습니다. 보험을 등록하면 자동 추적됩니다.</Text>
|
||||
</Card>
|
||||
) : (
|
||||
upcomingList.map((n: any) => (
|
||||
<Card key={n.policyId} style={{ marginBottom: 10 }} onPress={() => nav.navigate('Notifications')}>
|
||||
<View style={styles.rowBetween}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Badge label={`D-${n.days}`} tone={n.days <= 7 ? 'danger' : n.days <= 30 ? 'warning' : 'primary'} />
|
||||
<Text style={[typography.bodyBold as any, { marginTop: 6 }]}>{n.title}</Text>
|
||||
<Text style={styles.dim}>{new Date(n.scheduled).toLocaleDateString()}</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
|
||||
</View>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
{recentNotis.map((n: any) => (
|
||||
<Card key={n.id} style={{ marginBottom: 10 }} onPress={() => nav.navigate('Notifications')}>
|
||||
<View style={styles.rowBetween}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Badge
|
||||
label={n.tone === 'danger' ? '긴급' : n.tone === 'warn' ? '알림' : '안내'}
|
||||
tone={n.tone === 'danger' ? 'danger' : n.tone === 'warn' ? 'warning' : 'primary'}
|
||||
label={n.tone === 'DANGER' ? '긴급' : n.tone === 'WARN' ? '알림' : '안내'}
|
||||
tone={n.tone === 'DANGER' ? 'danger' : n.tone === 'WARN' ? 'warning' : 'primary'}
|
||||
/>
|
||||
<Text style={[typography.bodyBold as any, { marginTop: 6 }]}>{n.title}</Text>
|
||||
<Text style={styles.dim}>{n.body}</Text>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, RefreshControl, ScrollView, Alert, TextInput, TouchableOpacity, Modal } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
@@ -9,72 +9,201 @@ import Card from '@/components/Card';
|
||||
import Section from '@/components/Section';
|
||||
import Badge from '@/components/Badge';
|
||||
import Button from '@/components/Button';
|
||||
import { useAppStore } from '@/store/useAppStore';
|
||||
import { useDataStore } from '@/store/useDataStore';
|
||||
import { colors } from '@/theme/colors';
|
||||
import { spacing, typography } from '@/theme/typography';
|
||||
import { radius, spacing, typography } from '@/theme/typography';
|
||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||
import { policyApi, type Policy } from '@/api/endpoints';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
const POLICY_TYPES: Array<{ v: Policy['type']; label: string }> = [
|
||||
{ v: 'SILSON', label: '실손' },
|
||||
{ v: 'CANCER', label: '암' },
|
||||
{ v: 'LIFE', label: '종신' },
|
||||
{ v: 'ACCIDENT', label: '상해' },
|
||||
{ v: 'CHILD', label: '어린이' },
|
||||
{ v: 'NURSING', label: '간병' },
|
||||
{ v: 'FEMALE', label: '여성' },
|
||||
{ v: 'DENTAL', label: '치아' },
|
||||
{ v: 'DRIVER', label: '운전자' },
|
||||
{ v: 'CAR', label: '자동차' },
|
||||
];
|
||||
|
||||
export default function MyInsuranceScreen() {
|
||||
const nav = useNavigation<Nav>();
|
||||
const family = useAppStore((s) => s.family);
|
||||
const profile = useAppStore((s) => s.profile);
|
||||
const me = family.find((m) => m.relation === '본인');
|
||||
const totalMonthly = me?.policies.reduce((a, p) => a + p.monthlyPremium, 0) ?? 0;
|
||||
const totalCoverage = me?.policies.reduce((a, p) => a + p.coverage, 0) ?? 0;
|
||||
const { policies, fetchPolicies, fetchScore } = useDataStore();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchPolicies();
|
||||
}, [fetchPolicies]);
|
||||
|
||||
const own = policies.filter((p) => !p.familyMemberId);
|
||||
const totalMonthly = own.reduce((a, p) => a + p.monthlyPremium, 0);
|
||||
const totalCoverage = own.reduce((a, p) => a + p.coverage, 0);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
await fetchPolicies();
|
||||
setRefreshing(false);
|
||||
};
|
||||
|
||||
const onDelete = (id: string) => {
|
||||
Alert.alert('보험 삭제', '정말 삭제할까요?', [
|
||||
{ text: '취소', style: 'cancel' },
|
||||
{
|
||||
text: '삭제',
|
||||
style: 'destructive',
|
||||
onPress: async () => {
|
||||
try {
|
||||
await policyApi.remove(id);
|
||||
await fetchPolicies();
|
||||
await fetchScore();
|
||||
} catch (e: any) {
|
||||
Alert.alert('삭제 실패', e?.message);
|
||||
}
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenContainer>
|
||||
<Header title="내 보험" showBack={false} />
|
||||
<Card style={{ margin: spacing.lg }}>
|
||||
<Text style={styles.label}>이달 납입 보험료</Text>
|
||||
<Text style={styles.big}>{totalMonthly.toLocaleString()}원</Text>
|
||||
<View style={styles.divider} />
|
||||
<View style={{ flexDirection: 'row' }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.label}>총 보장금액</Text>
|
||||
<Text style={styles.mid}>{(totalCoverage / 10000).toLocaleString()}만원</Text>
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.label}>가입 보험</Text>
|
||||
<Text style={styles.mid}>{me?.policies.length ?? 0}건</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Section title="내 보험 목록">
|
||||
{me?.policies.map((p) => (
|
||||
<Card key={p.id} style={{ marginBottom: 10 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<View style={styles.icon}>
|
||||
<Ionicons name="shield-checkmark" size={22} color={colors.primary} />
|
||||
<>
|
||||
<ScreenContainer scroll={false}>
|
||||
<Header title="내 보험" showBack={false} />
|
||||
<ScrollView
|
||||
style={{ flex: 1 }}
|
||||
contentContainerStyle={{ paddingBottom: 40 }}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
||||
>
|
||||
<Card style={{ margin: spacing.lg }}>
|
||||
<Text style={styles.label}>이달 납입 보험료</Text>
|
||||
<Text style={styles.big}>{totalMonthly.toLocaleString()}원</Text>
|
||||
<View style={styles.divider} />
|
||||
<View style={{ flexDirection: 'row' }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.label}>총 보장금액</Text>
|
||||
<Text style={styles.mid}>{Math.round(totalCoverage / 10000).toLocaleString()}만원</Text>
|
||||
</View>
|
||||
<View style={{ flex: 1, marginLeft: 12 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||
<Badge label={p.type} tone="primary" />
|
||||
<Text style={styles.insurer}>{p.insurer}</Text>
|
||||
</View>
|
||||
<Text style={styles.name}>{p.name}</Text>
|
||||
<Text style={styles.dim}>
|
||||
월 {p.monthlyPremium.toLocaleString()}원 · 보장 {(p.coverage / 10000).toLocaleString()}만원
|
||||
</Text>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={styles.label}>가입 보험</Text>
|
||||
<Text style={styles.mid}>{own.length}건</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
|
||||
</View>
|
||||
</Card>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title="바로가기">
|
||||
<View style={{ gap: 8 }}>
|
||||
<Button title="내 보험 점수 상세보기" onPress={() => nav.navigate('Score')} />
|
||||
<Button title="보험료 다이어트 진단" variant="outline" onPress={() => nav.navigate('PremiumDiet')} />
|
||||
<Button title="실손 세대 판별" variant="outline" onPress={() => nav.navigate('SilsonGen')} />
|
||||
<Button title="가족 보험 한눈에 보기" variant="outline" onPress={() => nav.navigate('Family')} />
|
||||
</View>
|
||||
</Section>
|
||||
</ScreenContainer>
|
||||
<Section title="내 보험 목록" right={<TouchableOpacity onPress={() => setModalOpen(true)}><Text style={styles.addBtn}>+ 추가</Text></TouchableOpacity>}>
|
||||
{own.length === 0 && (
|
||||
<Card>
|
||||
<Text style={styles.dim}>아직 등록된 보험이 없습니다. + 추가 버튼으로 등록해 주세요.</Text>
|
||||
</Card>
|
||||
)}
|
||||
{own.map((p) => (
|
||||
<Card key={p.id} style={{ marginBottom: 10 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<View style={styles.icon}>
|
||||
<Ionicons name="shield-checkmark" size={22} color={colors.primary} />
|
||||
</View>
|
||||
<View style={{ flex: 1, marginLeft: 12 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||
<Badge label={POLICY_TYPES.find((t) => t.v === p.type)?.label ?? p.type} tone="primary" />
|
||||
<Text style={styles.insurer}>{p.insurer}</Text>
|
||||
</View>
|
||||
<Text style={styles.name}>{p.name}</Text>
|
||||
<Text style={styles.dim}>
|
||||
월 {p.monthlyPremium.toLocaleString()}원 · 보장 {Math.round(p.coverage / 10000).toLocaleString()}만원
|
||||
</Text>
|
||||
</View>
|
||||
<TouchableOpacity onPress={() => onDelete(p.id)} hitSlop={10}>
|
||||
<Ionicons name="trash-outline" size={20} color={colors.textTertiary} />
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</Card>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<Section title="바로가기">
|
||||
<View style={{ gap: 8 }}>
|
||||
<Button title="내 보험 점수 상세보기" onPress={() => nav.navigate('Score')} />
|
||||
<Button title="보험료 다이어트 진단" variant="outline" onPress={() => nav.navigate('PremiumDiet')} />
|
||||
<Button title="실손 세대 판별" variant="outline" onPress={() => nav.navigate('SilsonGen')} />
|
||||
<Button title="가족 보험 한눈에 보기" variant="outline" onPress={() => nav.navigate('Family')} />
|
||||
</View>
|
||||
</Section>
|
||||
</ScrollView>
|
||||
</ScreenContainer>
|
||||
<AddPolicyModal open={modalOpen} onClose={() => setModalOpen(false)} onCreated={() => { fetchPolicies(); fetchScore(); }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function AddPolicyModal({ open, onClose, onCreated }: { open: boolean; onClose: () => void; onCreated: () => void }) {
|
||||
const [name, setName] = useState('');
|
||||
const [insurer, setInsurer] = useState('');
|
||||
const [type, setType] = useState<Policy['type']>('SILSON');
|
||||
const [monthlyPremium, setMonthlyPremium] = useState('');
|
||||
const [coverage, setCoverage] = useState('');
|
||||
const [joinDate, setJoinDate] = useState(new Date().toISOString().slice(0, 10));
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!name || !insurer || !monthlyPremium || !coverage) {
|
||||
Alert.alert('필수 항목을 모두 입력하세요');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await policyApi.create({
|
||||
name,
|
||||
insurer,
|
||||
type,
|
||||
monthlyPremium: parseInt(monthlyPremium, 10),
|
||||
coverage: parseInt(coverage, 10),
|
||||
joinDate,
|
||||
} as any);
|
||||
onCreated();
|
||||
onClose();
|
||||
setName('');
|
||||
setInsurer('');
|
||||
setMonthlyPremium('');
|
||||
setCoverage('');
|
||||
} catch (e: any) {
|
||||
Alert.alert('등록 실패', e?.message);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal visible={open} animationType="slide" onRequestClose={onClose} transparent={false}>
|
||||
<ScreenContainer scroll={false}>
|
||||
<Header title="보험 추가" showBack={false} right={<TouchableOpacity onPress={onClose}><Ionicons name="close" size={24} color={colors.text} /></TouchableOpacity>} />
|
||||
<ScrollView contentContainerStyle={{ padding: spacing.lg, paddingBottom: 40 }}>
|
||||
<Text style={styles.modalLabel}>보험 종류</Text>
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>
|
||||
{POLICY_TYPES.map((t) => (
|
||||
<TouchableOpacity key={t.v} style={[styles.pill, type === t.v && styles.pillActive]} onPress={() => setType(t.v)}>
|
||||
<Text style={[styles.pillText, type === t.v && styles.pillTextActive]}>{t.label}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
<Text style={[styles.modalLabel, { marginTop: 16 }]}>상품명 *</Text>
|
||||
<TextInput style={styles.input} value={name} onChangeText={setName} placeholder="예) 4세대 실손의료비" placeholderTextColor={colors.textTertiary} />
|
||||
<Text style={[styles.modalLabel, { marginTop: 12 }]}>보험사 *</Text>
|
||||
<TextInput style={styles.input} value={insurer} onChangeText={setInsurer} placeholder="예) 삼성생명" placeholderTextColor={colors.textTertiary} />
|
||||
<Text style={[styles.modalLabel, { marginTop: 12 }]}>월 보험료 (원) *</Text>
|
||||
<TextInput style={styles.input} value={monthlyPremium} onChangeText={setMonthlyPremium} placeholder="예) 32000" keyboardType="number-pad" placeholderTextColor={colors.textTertiary} />
|
||||
<Text style={[styles.modalLabel, { marginTop: 12 }]}>보장금액 (원) *</Text>
|
||||
<TextInput style={styles.input} value={coverage} onChangeText={setCoverage} placeholder="예) 50000000" keyboardType="number-pad" placeholderTextColor={colors.textTertiary} />
|
||||
<Text style={[styles.modalLabel, { marginTop: 12 }]}>가입일</Text>
|
||||
<TextInput style={styles.input} value={joinDate} onChangeText={setJoinDate} placeholder="YYYY-MM-DD" placeholderTextColor={colors.textTertiary} />
|
||||
<View style={{ height: 24 }} />
|
||||
<Button title="등록하기" size="lg" onPress={onSubmit} loading={submitting} />
|
||||
</ScrollView>
|
||||
</ScreenContainer>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -94,4 +223,19 @@ const styles = StyleSheet.create({
|
||||
insurer: { ...typography.caption, color: colors.textSecondary },
|
||||
name: { ...typography.bodyBold, color: colors.text, marginTop: 2 },
|
||||
dim: { ...typography.caption, color: colors.textSecondary, marginTop: 2 },
|
||||
addBtn: { color: colors.primary, fontWeight: '700' },
|
||||
modalLabel: { ...typography.caption, color: colors.textSecondary, fontWeight: '600', marginBottom: 6 },
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: radius.md,
|
||||
padding: 12,
|
||||
fontSize: 15,
|
||||
color: colors.text,
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
pill: { paddingHorizontal: 12, paddingVertical: 8, borderRadius: radius.pill, backgroundColor: colors.surfaceAlt, borderWidth: 1, borderColor: colors.border },
|
||||
pillActive: { backgroundColor: colors.primary, borderColor: colors.primary },
|
||||
pillText: { ...typography.small, color: colors.text },
|
||||
pillTextActive: { color: '#FFF', fontWeight: '700' },
|
||||
});
|
||||
|
||||
@@ -8,7 +8,8 @@ import Header from '@/components/Header';
|
||||
import Card from '@/components/Card';
|
||||
import Section from '@/components/Section';
|
||||
import Badge from '@/components/Badge';
|
||||
import { useAppStore } from '@/store/useAppStore';
|
||||
import { useAuthStore } from '@/store/useAuthStore';
|
||||
import Button from '@/components/Button';
|
||||
import { colors } from '@/theme/colors';
|
||||
import { spacing, typography } from '@/theme/typography';
|
||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||
@@ -28,7 +29,8 @@ const menu = [
|
||||
|
||||
export default function MyPageScreen() {
|
||||
const nav = useNavigation<Nav>();
|
||||
const profile = useAppStore((s) => s.profile);
|
||||
const user = useAuthStore((s) => s.user);
|
||||
const logout = useAuthStore((s) => s.logout);
|
||||
|
||||
return (
|
||||
<ScreenContainer>
|
||||
@@ -40,12 +42,13 @@ export default function MyPageScreen() {
|
||||
<Ionicons name="person" size={32} color={colors.primary} />
|
||||
</View>
|
||||
<View style={{ flex: 1, marginLeft: 14 }}>
|
||||
<Text style={typography.h3 as any}>{profile.name}님</Text>
|
||||
<Text style={typography.h3 as any}>{user?.name ?? '사용자'}님</Text>
|
||||
<Text style={styles.dim}>
|
||||
{profile.age}세 · {profile.gender} · {profile.job}
|
||||
{user?.profile?.age ?? '-'}세 · {user?.profile?.gender === 'FEMALE' ? '여' : '남'} · {user?.profile?.job ?? '-'}
|
||||
</Text>
|
||||
<View style={{ marginTop: 6 }}>
|
||||
<Badge label={`보험점수 ${profile.score}점`} tone="primary" />
|
||||
<View style={{ marginTop: 6, flexDirection: 'row', gap: 6 }}>
|
||||
<Badge label={`보험점수 ${user?.profile?.score ?? 0}점`} tone="primary" />
|
||||
{user?.provider === 'KAKAO' && <Badge label="카카오" tone="warning" />}
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
@@ -68,6 +71,9 @@ export default function MyPageScreen() {
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<View style={{ paddingHorizontal: spacing.lg, marginTop: 16 }}>
|
||||
<Button title="로그아웃" variant="outline" onPress={logout} />
|
||||
</View>
|
||||
<View style={{ alignItems: 'center', marginTop: 24 }}>
|
||||
<Text style={styles.dim}>보험케어 v1.0.0</Text>
|
||||
</View>
|
||||
|
||||
+53
-26
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet } from 'react-native';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { View, Text, StyleSheet, RefreshControl, ScrollView } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
@@ -10,7 +10,8 @@ import Section from '@/components/Section';
|
||||
import Button from '@/components/Button';
|
||||
import ScoreGauge from '@/components/ScoreGauge';
|
||||
import ProgressBar from '@/components/ProgressBar';
|
||||
import { useAppStore } from '@/store/useAppStore';
|
||||
import { useDataStore } from '@/store/useDataStore';
|
||||
import { scoreApi } from '@/api/endpoints';
|
||||
import { colors } from '@/theme/colors';
|
||||
import { spacing, typography } from '@/theme/typography';
|
||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||
@@ -26,26 +27,51 @@ const statusMap = {
|
||||
|
||||
export default function ScoreScreen() {
|
||||
const nav = useNavigation<Nav>();
|
||||
const score = useAppStore((s) => s.score);
|
||||
const { score, fetchScore } = useDataStore();
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchScore();
|
||||
}, [fetchScore]);
|
||||
|
||||
const onRefresh = async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await scoreApi.recompute();
|
||||
await fetchScore();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const total = score?.total ?? 0;
|
||||
const breakdown = score?.breakdown ?? [];
|
||||
|
||||
return (
|
||||
<ScreenContainer>
|
||||
<ScreenContainer scroll={false}>
|
||||
<Header title="내 보험 점수" />
|
||||
<View style={{ padding: spacing.lg }}>
|
||||
<ScrollView
|
||||
contentContainerStyle={{ padding: spacing.lg, paddingBottom: 40 }}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
||||
>
|
||||
<Card padding="xl">
|
||||
<View style={{ alignItems: 'center' }}>
|
||||
<ScoreGauge value={score.total} size={200} />
|
||||
<ScoreGauge value={total} size={200} />
|
||||
</View>
|
||||
<View style={{ marginTop: 16, padding: 14, backgroundColor: colors.primaryLight, borderRadius: 12 }}>
|
||||
<Text style={{ color: colors.primaryDark, fontWeight: '700' }}>
|
||||
💡 상위 15% 수준이에요. 몇 가지 보완하면 90점 이상 가능!
|
||||
{total >= 80
|
||||
? '💪 양호한 수준입니다. 부족한 항목만 채우면 90점+'
|
||||
: total >= 60
|
||||
? '🟡 기본은 있지만 취약 항목이 있어요'
|
||||
: '🔴 현재 보장이 크게 부족합니다. 상담 권장'}
|
||||
</Text>
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
<Section title="항목별 점수">
|
||||
{score.breakdown.map((b) => {
|
||||
const s = statusMap[b.status];
|
||||
{breakdown.map((b) => {
|
||||
const s = statusMap[b.status as keyof typeof statusMap];
|
||||
return (
|
||||
<Card key={b.label} style={{ marginBottom: 10 }}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
@@ -63,28 +89,29 @@ export default function ScoreScreen() {
|
||||
</Section>
|
||||
|
||||
<Section title="⚡ 점수 올리는 법">
|
||||
{[
|
||||
{ title: '간병보험 가입', desc: '60대 이후 필수. +15점', route: 'Consult' },
|
||||
{ title: '종신보험 보장 강화', desc: '자녀 자산 상속 대비. +10점', route: 'Consult' },
|
||||
{ title: '치아보험 추가', desc: '중년기 치과 비용 대비. +5점', route: 'Consult' },
|
||||
].map((a) => (
|
||||
<Card key={a.title} style={{ marginBottom: 10 }} onPress={() => nav.navigate(a.route as any)}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={typography.bodyBold as any}>{a.title}</Text>
|
||||
<Text style={{ ...typography.caption, color: colors.textSecondary } as any}>{a.desc}</Text>
|
||||
{breakdown
|
||||
.filter((b) => b.status !== 'good')
|
||||
.slice(0, 3)
|
||||
.map((b) => (
|
||||
<Card key={b.label} style={{ marginBottom: 10 }} onPress={() => nav.navigate('Consult')}>
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={typography.bodyBold as any}>{b.label} {b.status === 'none' ? '가입' : '보장 강화'}</Text>
|
||||
<Text style={{ ...typography.caption, color: colors.textSecondary } as any}>
|
||||
{b.status === 'none' ? '미가입 상태. 필수 점검' : `현재 ${b.value}점 — 상담 통해 보완`}
|
||||
</Text>
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
|
||||
</View>
|
||||
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
|
||||
</View>
|
||||
</Card>
|
||||
))}
|
||||
</Card>
|
||||
))}
|
||||
</Section>
|
||||
|
||||
<View style={{ paddingHorizontal: 0, paddingTop: 8, gap: 8 }}>
|
||||
<View style={{ paddingTop: 8, gap: 8 }}>
|
||||
<Button title="맞춤 상담 받기" onPress={() => nav.navigate('Consult')} />
|
||||
<Button title="보험료 다이어트 진단" variant="outline" onPress={() => nav.navigate('PremiumDiet')} />
|
||||
</View>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ScreenContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, TextInput, TouchableOpacity, Alert, Platform } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import ScreenContainer from '@/components/ScreenContainer';
|
||||
import Button from '@/components/Button';
|
||||
import { colors } from '@/theme/colors';
|
||||
import { radius, spacing, typography } from '@/theme/typography';
|
||||
import { useAuthStore } from '@/store/useAuthStore';
|
||||
|
||||
export default function LoginScreen() {
|
||||
const nav = useNavigation<any>();
|
||||
const login = useAuthStore((s) => s.login);
|
||||
const kakaoLogin = useAuthStore((s) => s.kakaoLogin);
|
||||
const loading = useAuthStore((s) => s.loading);
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!email || !password) {
|
||||
Alert.alert('이메일과 비밀번호를 입력하세요');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await login(email, password);
|
||||
} catch (e: any) {
|
||||
Alert.alert('로그인 실패', e?.message ?? '다시 시도해 주세요');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onKakao = async () => {
|
||||
if (Platform.OS === 'web') {
|
||||
const input = window.prompt('[개발용] 카카오 Access Token을 입력하세요\n실환경에서는 Kakao JS SDK로 자동 획득됩니다.');
|
||||
if (!input) return;
|
||||
try {
|
||||
await kakaoLogin(input);
|
||||
} catch (e: any) {
|
||||
Alert.alert('카카오 로그인 실패', e?.message ?? '');
|
||||
}
|
||||
return;
|
||||
}
|
||||
Alert.alert(
|
||||
'카카오 로그인',
|
||||
'Native 카카오 SDK (@react-native-seoul/kakao-login) 연동이 필요합니다. 빌드 후 테스트 가능합니다.'
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenContainer edges={['top']}>
|
||||
<View style={{ padding: spacing.xxl, paddingTop: 40 }}>
|
||||
<LinearGradient colors={colors.gradient.primary} style={styles.hero}>
|
||||
<Ionicons name="shield-checkmark" size={40} color="#FFF" />
|
||||
<Text style={styles.heroTitle}>보험케어</Text>
|
||||
<Text style={styles.heroSub}>내 보험, 가족 보험 한눈에</Text>
|
||||
</LinearGradient>
|
||||
|
||||
<View style={{ marginTop: 28, gap: 12 }}>
|
||||
<View>
|
||||
<Text style={styles.label}>이메일</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="you@example.com"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
/>
|
||||
</View>
|
||||
<View>
|
||||
<Text style={styles.label}>비밀번호</Text>
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="8자 이상"
|
||||
secureTextEntry
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
/>
|
||||
</View>
|
||||
|
||||
<View style={{ marginTop: 8 }}>
|
||||
<Button title="로그인" size="lg" onPress={onSubmit} loading={submitting || loading} />
|
||||
</View>
|
||||
|
||||
<View style={styles.divider}>
|
||||
<View style={styles.line} />
|
||||
<Text style={styles.divText}>또는</Text>
|
||||
<View style={styles.line} />
|
||||
</View>
|
||||
|
||||
<Button
|
||||
title="카카오로 3초 시작"
|
||||
variant="kakao"
|
||||
size="lg"
|
||||
leftIcon={<Ionicons name="chatbubble" size={18} color="#191600" />}
|
||||
onPress={onKakao}
|
||||
/>
|
||||
|
||||
<TouchableOpacity style={styles.signupRow} onPress={() => nav.navigate('Register')}>
|
||||
<Text style={styles.signupText}>
|
||||
계정이 없으신가요? <Text style={styles.signupLink}>회원가입</Text>
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</ScreenContainer>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
hero: {
|
||||
padding: 28,
|
||||
borderRadius: radius.xl,
|
||||
alignItems: 'center',
|
||||
},
|
||||
heroTitle: { color: '#FFF', fontSize: 26, fontWeight: '800', marginTop: 8 },
|
||||
heroSub: { color: 'rgba(255,255,255,0.85)', fontSize: 14, marginTop: 6 },
|
||||
label: { ...typography.caption, color: colors.textSecondary, fontWeight: '600', marginBottom: 6 },
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: radius.md,
|
||||
padding: 14,
|
||||
fontSize: 15,
|
||||
color: colors.text,
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
divider: { flexDirection: 'row', alignItems: 'center', marginVertical: 4 },
|
||||
line: { flex: 1, height: 1, backgroundColor: colors.border },
|
||||
divText: { ...typography.small, color: colors.textSecondary, marginHorizontal: 10 },
|
||||
signupRow: { alignItems: 'center', marginTop: 10, paddingVertical: 10 },
|
||||
signupText: { ...typography.caption, color: colors.textSecondary },
|
||||
signupLink: { color: colors.primary, fontWeight: '700' },
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, TextInput, TouchableOpacity, Alert, ScrollView } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import ScreenContainer from '@/components/ScreenContainer';
|
||||
import Header from '@/components/Header';
|
||||
import Button from '@/components/Button';
|
||||
import { colors } from '@/theme/colors';
|
||||
import { radius, spacing, typography } from '@/theme/typography';
|
||||
import { useAuthStore } from '@/store/useAuthStore';
|
||||
|
||||
export default function RegisterScreen() {
|
||||
const nav = useNavigation<any>();
|
||||
const register = useAuthStore((s) => s.register);
|
||||
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [password2, setPassword2] = useState('');
|
||||
const [name, setName] = useState('');
|
||||
const [phone, setPhone] = useState('');
|
||||
const [age, setAge] = useState('');
|
||||
const [gender, setGender] = useState<'MALE' | 'FEMALE'>('MALE');
|
||||
const [job, setJob] = useState('사무직');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!email || !password || !name || !age) {
|
||||
Alert.alert('필수 항목을 모두 입력하세요');
|
||||
return;
|
||||
}
|
||||
if (password.length < 8) {
|
||||
Alert.alert('비밀번호는 8자 이상이어야 합니다');
|
||||
return;
|
||||
}
|
||||
if (password !== password2) {
|
||||
Alert.alert('비밀번호가 일치하지 않습니다');
|
||||
return;
|
||||
}
|
||||
const ageNum = parseInt(age, 10);
|
||||
if (isNaN(ageNum) || ageNum < 0 || ageNum > 120) {
|
||||
Alert.alert('나이를 올바르게 입력하세요');
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await register({ email, password, name, phone, age: ageNum, gender, job });
|
||||
} catch (e: any) {
|
||||
Alert.alert('회원가입 실패', e?.message ?? '다시 시도해 주세요');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ScreenContainer scroll={false}>
|
||||
<Header title="회원가입" />
|
||||
<ScrollView style={{ flex: 1 }} contentContainerStyle={{ padding: spacing.xxl, paddingBottom: 48 }}>
|
||||
<Text style={typography.h3 as any}>기본 정보</Text>
|
||||
|
||||
<Field label="이메일 *">
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="you@example.com"
|
||||
keyboardType="email-address"
|
||||
autoCapitalize="none"
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="비밀번호 *">
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="8자 이상"
|
||||
secureTextEntry
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="비밀번호 확인 *">
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="다시 한 번"
|
||||
secureTextEntry
|
||||
value={password2}
|
||||
onChangeText={setPassword2}
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="이름 *">
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="홍길동"
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="휴대전화">
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="010-0000-0000"
|
||||
keyboardType="phone-pad"
|
||||
value={phone}
|
||||
onChangeText={setPhone}
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<View style={{ height: 20 }} />
|
||||
<Text style={typography.h3 as any}>프로필</Text>
|
||||
|
||||
<Field label="나이 *">
|
||||
<TextInput
|
||||
style={styles.input}
|
||||
placeholder="34"
|
||||
keyboardType="number-pad"
|
||||
value={age}
|
||||
onChangeText={setAge}
|
||||
placeholderTextColor={colors.textTertiary}
|
||||
/>
|
||||
</Field>
|
||||
<Field label="성별">
|
||||
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||
{([
|
||||
{ v: 'MALE' as const, l: '남성' },
|
||||
{ v: 'FEMALE' as const, l: '여성' },
|
||||
]).map((o) => (
|
||||
<TouchableOpacity
|
||||
key={o.v}
|
||||
style={[styles.pill, gender === o.v && styles.pillActive]}
|
||||
onPress={() => setGender(o.v)}
|
||||
>
|
||||
<Text style={[styles.pillText, gender === o.v && styles.pillTextActive]}>{o.l}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</Field>
|
||||
<Field label="직업">
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 8 }}>
|
||||
{['사무직', '생산직', '전문직', '학생', '주부', '기타'].map((j) => (
|
||||
<TouchableOpacity key={j} style={[styles.pill, job === j && styles.pillActive]} onPress={() => setJob(j)}>
|
||||
<Text style={[styles.pillText, job === j && styles.pillTextActive]}>{j}</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
</Field>
|
||||
|
||||
<View style={{ height: 24 }} />
|
||||
<Button title="가입하기" size="lg" onPress={onSubmit} loading={submitting} />
|
||||
</ScrollView>
|
||||
</ScreenContainer>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<View style={{ marginTop: 14 }}>
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
{children}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
label: { ...typography.caption, color: colors.textSecondary, fontWeight: '600', marginBottom: 6 },
|
||||
input: {
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
borderRadius: radius.md,
|
||||
padding: 14,
|
||||
fontSize: 15,
|
||||
color: colors.text,
|
||||
backgroundColor: colors.surface,
|
||||
},
|
||||
pill: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: radius.pill,
|
||||
backgroundColor: colors.surfaceAlt,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.border,
|
||||
},
|
||||
pillActive: { backgroundColor: colors.primary, borderColor: colors.primary },
|
||||
pillText: { color: colors.text, fontWeight: '500' },
|
||||
pillTextActive: { color: '#FFF', fontWeight: '700' },
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
import { create } from 'zustand';
|
||||
import { authApi, type User } from '@/api/endpoints';
|
||||
import { clearToken, loadToken, saveToken } from '@/api/client';
|
||||
|
||||
type State = {
|
||||
user: User | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
hydrate: () => Promise<void>;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
register: (body: Parameters<typeof authApi.register>[0]) => Promise<void>;
|
||||
kakaoLogin: (accessToken: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const useAuthStore = create<State>((set) => ({
|
||||
user: null,
|
||||
loading: true,
|
||||
error: null,
|
||||
|
||||
hydrate: async () => {
|
||||
const tok = await loadToken();
|
||||
if (!tok) {
|
||||
set({ loading: false });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const me = await authApi.me();
|
||||
set({ user: me, loading: false });
|
||||
} catch {
|
||||
await clearToken();
|
||||
set({ user: null, loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
login: async (email, password) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const res = await authApi.login(email, password);
|
||||
await saveToken(res.token);
|
||||
set({ user: res.user, loading: false });
|
||||
} catch (e: any) {
|
||||
set({ loading: false, error: e?.message ?? '로그인 실패' });
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
register: async (body) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const res = await authApi.register(body);
|
||||
await saveToken(res.token);
|
||||
set({ user: res.user, loading: false });
|
||||
} catch (e: any) {
|
||||
set({ loading: false, error: e?.message ?? '회원가입 실패' });
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
kakaoLogin: async (accessToken) => {
|
||||
set({ loading: true, error: null });
|
||||
try {
|
||||
const res = await authApi.kakao(accessToken);
|
||||
await saveToken(res.token);
|
||||
set({ user: res.user, loading: false });
|
||||
} catch (e: any) {
|
||||
set({ loading: false, error: e?.message ?? '카카오 로그인 실패' });
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
|
||||
logout: async () => {
|
||||
await clearToken();
|
||||
set({ user: null });
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,88 @@
|
||||
import { create } from 'zustand';
|
||||
import { familyApi, type FamilyMember, policyApi, type Policy, claimApi, type Claim, scoreApi, notificationApi } from '@/api/endpoints';
|
||||
|
||||
type ScoreState = { total: number; breakdown: Array<{ label: string; value: number; status: string }> };
|
||||
|
||||
type State = {
|
||||
family: FamilyMember[];
|
||||
policies: Policy[];
|
||||
claims: Claim[];
|
||||
score: ScoreState | null;
|
||||
notifications: any[];
|
||||
upcoming: any[];
|
||||
|
||||
loading: { [k: string]: boolean };
|
||||
|
||||
fetchAll: () => Promise<void>;
|
||||
fetchFamily: () => Promise<void>;
|
||||
fetchPolicies: () => Promise<void>;
|
||||
fetchClaims: () => Promise<void>;
|
||||
fetchScore: () => Promise<void>;
|
||||
fetchNotifications: () => Promise<void>;
|
||||
};
|
||||
|
||||
export const useDataStore = create<State>((set, get) => ({
|
||||
family: [],
|
||||
policies: [],
|
||||
claims: [],
|
||||
score: null,
|
||||
notifications: [],
|
||||
upcoming: [],
|
||||
loading: {},
|
||||
|
||||
fetchAll: async () => {
|
||||
await Promise.all([
|
||||
get().fetchFamily(),
|
||||
get().fetchPolicies(),
|
||||
get().fetchClaims(),
|
||||
get().fetchScore(),
|
||||
get().fetchNotifications(),
|
||||
]);
|
||||
},
|
||||
|
||||
fetchFamily: async () => {
|
||||
set((s) => ({ loading: { ...s.loading, family: true } }));
|
||||
try {
|
||||
const family = await familyApi.list();
|
||||
set({ family });
|
||||
} finally {
|
||||
set((s) => ({ loading: { ...s.loading, family: false } }));
|
||||
}
|
||||
},
|
||||
fetchPolicies: async () => {
|
||||
set((s) => ({ loading: { ...s.loading, policies: true } }));
|
||||
try {
|
||||
const policies = await policyApi.list();
|
||||
set({ policies });
|
||||
} finally {
|
||||
set((s) => ({ loading: { ...s.loading, policies: false } }));
|
||||
}
|
||||
},
|
||||
fetchClaims: async () => {
|
||||
set((s) => ({ loading: { ...s.loading, claims: true } }));
|
||||
try {
|
||||
const claims = await claimApi.list();
|
||||
set({ claims });
|
||||
} finally {
|
||||
set((s) => ({ loading: { ...s.loading, claims: false } }));
|
||||
}
|
||||
},
|
||||
fetchScore: async () => {
|
||||
set((s) => ({ loading: { ...s.loading, score: true } }));
|
||||
try {
|
||||
const score = await scoreApi.get();
|
||||
set({ score });
|
||||
} finally {
|
||||
set((s) => ({ loading: { ...s.loading, score: false } }));
|
||||
}
|
||||
},
|
||||
fetchNotifications: async () => {
|
||||
set((s) => ({ loading: { ...s.loading, noti: true } }));
|
||||
try {
|
||||
const [notifications, upcoming] = await Promise.all([notificationApi.list(), notificationApi.upcoming()]);
|
||||
set({ notifications, upcoming });
|
||||
} finally {
|
||||
set((s) => ({ loading: { ...s.loading, noti: false } }));
|
||||
}
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user