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:
|
on:
|
||||||
push:
|
push:
|
||||||
branches:
|
branches: [master, main]
|
||||||
- master
|
|
||||||
- main
|
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
env:
|
env:
|
||||||
REGISTRY: git.junggomoa.com
|
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:
|
jobs:
|
||||||
build-and-deploy:
|
build-and-deploy:
|
||||||
@@ -31,16 +31,31 @@ jobs:
|
|||||||
username: ${{ secrets.REGISTRY_USER }}
|
username: ${{ secrets.REGISTRY_USER }}
|
||||||
password: ${{ secrets.REGISTRY_TOKEN }}
|
password: ${{ secrets.REGISTRY_TOKEN }}
|
||||||
|
|
||||||
- name: Build and push image
|
- name: Build & push WEB image
|
||||||
uses: docker/build-push-action@v5
|
uses: docker/build-push-action@v5
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
|
file: ./Dockerfile
|
||||||
|
build-args: |
|
||||||
|
EXPO_PUBLIC_API_BASE=${{ env.API_BASE_URL }}
|
||||||
push: true
|
push: true
|
||||||
tags: |
|
tags: |
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
${{ env.REGISTRY }}/${{ env.WEB_IMAGE }}:latest
|
||||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.SHORT_SHA }}
|
${{ env.REGISTRY }}/${{ env.WEB_IMAGE }}:${{ env.SHORT_SHA }}
|
||||||
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
|
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.WEB_IMAGE }}:buildcache
|
||||||
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
|
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
|
- name: Set up kubectl
|
||||||
uses: azure/setup-kubectl@v4
|
uses: azure/setup-kubectl@v4
|
||||||
@@ -53,16 +68,40 @@ jobs:
|
|||||||
echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > $HOME/.kube/config
|
echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > $HOME/.kube/config
|
||||||
chmod 600 $HOME/.kube/config
|
chmod 600 $HOME/.kube/config
|
||||||
|
|
||||||
- name: Ensure namespace & registry secret
|
- name: Ensure namespace, registry & DB secrets
|
||||||
run: |
|
run: |
|
||||||
kubectl apply -f deploy/k8s/namespace.yaml
|
kubectl apply -f deploy/k8s/namespace.yaml
|
||||||
|
|
||||||
kubectl -n insurance create secret docker-registry gitea-registry \
|
kubectl -n insurance create secret docker-registry gitea-registry \
|
||||||
--docker-server=${{ env.REGISTRY }} \
|
--docker-server=${{ env.REGISTRY }} \
|
||||||
--docker-username=${{ secrets.REGISTRY_USER }} \
|
--docker-username=${{ secrets.REGISTRY_USER }} \
|
||||||
--docker-password=${{ secrets.REGISTRY_TOKEN }} \
|
--docker-password=${{ secrets.REGISTRY_TOKEN }} \
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
--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: |
|
run: |
|
||||||
kubectl apply -f deploy/k8s/deployment.yaml
|
kubectl apply -f deploy/k8s/deployment.yaml
|
||||||
kubectl apply -f deploy/k8s/service.yaml
|
kubectl apply -f deploy/k8s/service.yaml
|
||||||
@@ -71,15 +110,13 @@ jobs:
|
|||||||
else
|
else
|
||||||
kubectl apply -f deploy/k8s/ingress.yaml
|
kubectl apply -f deploy/k8s/ingress.yaml
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Update deployment image & restart
|
|
||||||
run: |
|
|
||||||
kubectl -n insurance set image deployment/insurance-web \
|
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
|
kubectl -n insurance rollout status deployment/insurance-web --timeout=180s
|
||||||
|
|
||||||
- name: Show deployment info
|
- name: Show deployment info
|
||||||
run: |
|
run: |
|
||||||
kubectl -n insurance get deployment,svc,ingress
|
kubectl -n insurance get deployment,statefulset,svc,ingress,pvc
|
||||||
echo ""
|
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
|
||||||
.env.local
|
.env.local
|
||||||
*.log
|
*.log
|
||||||
|
.claude/
|
||||||
|
|||||||
@@ -1,16 +1,49 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
import { View, StyleSheet, Platform } from 'react-native';
|
||||||
import { StatusBar } from 'expo-status-bar';
|
import { StatusBar } from 'expo-status-bar';
|
||||||
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
import { SafeAreaProvider } from 'react-native-safe-area-context';
|
||||||
import { NavigationContainer } from '@react-navigation/native';
|
import { NavigationContainer } from '@react-navigation/native';
|
||||||
import RootNavigator from './src/navigation/RootNavigator';
|
import RootNavigator from './src/navigation/RootNavigator';
|
||||||
|
import ErrorBoundary from './src/components/ErrorBoundary';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const isWeb = Platform.OS === 'web';
|
||||||
return (
|
return (
|
||||||
|
<ErrorBoundary>
|
||||||
<SafeAreaProvider>
|
<SafeAreaProvider>
|
||||||
|
<View style={[styles.outer, isWeb && styles.webOuter]}>
|
||||||
|
<View style={[styles.inner, isWeb && styles.webInner]}>
|
||||||
<NavigationContainer>
|
<NavigationContainer>
|
||||||
<StatusBar style="dark" />
|
<StatusBar style="dark" />
|
||||||
<RootNavigator />
|
<RootNavigator />
|
||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
</SafeAreaProvider>
|
</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
|
git push → Gitea Actions
|
||||||
↓
|
├─ Web Docker build → insurance.junggomoa.com (nginx)
|
||||||
insurance.junggomoa.com
|
├─ 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)
|
| Secret | 값 | 비고 |
|
||||||
|
|
||||||
#### ① 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 | 설명 |
|
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `REGISTRY_USER` | `chpark` | Gitea 사용자명 |
|
| `REGISTRY_USER` | `chpark` | Gitea 사용자명 |
|
||||||
| `REGISTRY_TOKEN` | (위에서 발급한 토큰) | Container Registry 인증 |
|
| `REGISTRY_TOKEN` | (Gitea → Settings → Applications → Generate Token, `write:package` 체크) | |
|
||||||
| `KUBE_CONFIG` | (아래 2단계에서 생성) | base64 인코딩된 kubeconfig |
|
| `KUBE_CONFIG` | 서버에서 생성한 base64 kubeconfig | 아래 스크립트 참고 |
|
||||||
| `INGRESS_MODE` | `ingress` 또는 `ingressroute` | Traefik 설치 방식 (아래 확인) |
|
| `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
|
```bash
|
||||||
ssh chpark@183.99.177.40
|
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 create namespace insurance 2>/dev/null || true
|
||||||
kubectl -n insurance create serviceaccount gitea-deployer
|
kubectl -n insurance create serviceaccount gitea-deployer
|
||||||
kubectl create clusterrolebinding gitea-deployer \
|
kubectl create clusterrolebinding gitea-deployer \
|
||||||
--clusterrole=cluster-admin \
|
--clusterrole=cluster-admin \
|
||||||
--serviceaccount=insurance:gitea-deployer
|
--serviceaccount=insurance:gitea-deployer
|
||||||
|
|
||||||
# 토큰 생성 (24시간 기본, 필요 시 --duration=8760h 로 1년)
|
|
||||||
TOKEN=$(kubectl -n insurance create token gitea-deployer --duration=8760h)
|
TOKEN=$(kubectl -n insurance create token gitea-deployer --duration=8760h)
|
||||||
|
|
||||||
# 현재 API 서버 주소
|
|
||||||
SERVER=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')
|
SERVER=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')
|
||||||
CA=$(kubectl -n insurance get secret \
|
CA=$(kubectl config view --minify --raw -o jsonpath='{.clusters[0].cluster.certificate-authority-data}')
|
||||||
$(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}')
|
|
||||||
|
|
||||||
# kubeconfig 생성
|
cat > /tmp/gitea-kubeconfig <<EOF
|
||||||
cat <<EOF > /tmp/gitea-kubeconfig
|
|
||||||
apiVersion: v1
|
apiVersion: v1
|
||||||
kind: Config
|
kind: Config
|
||||||
clusters:
|
clusters:
|
||||||
@@ -93,99 +55,85 @@ users:
|
|||||||
token: ${TOKEN}
|
token: ${TOKEN}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
# base64 인코딩 → Gitea secret에 넣을 값
|
|
||||||
base64 -w0 /tmp/gitea-kubeconfig
|
base64 -w0 /tmp/gitea-kubeconfig
|
||||||
```
|
```
|
||||||
출력된 긴 문자열을 복사해서 Gitea의 `KUBE_CONFIG` secret에 붙여넣으세요.
|
|
||||||
|
|
||||||
#### ③ DNS 확인
|
출력된 긴 문자열 → `KUBE_CONFIG` 시크릿에 붙여넣기.
|
||||||
`insurance.junggomoa.com` 이 k8s 클러스터의 Traefik LoadBalancer IP 로 등록돼 있어야 합니다.
|
|
||||||
|
## 🌐 DNS
|
||||||
|
|
||||||
|
A 레코드 2개 필요:
|
||||||
|
- `insurance.junggomoa.com` → Traefik LoadBalancer IP
|
||||||
|
- `api.insurance.junggomoa.com` → Traefik LoadBalancer IP
|
||||||
|
|
||||||
|
## 💻 로컬 개발 (docker-compose)
|
||||||
|
|
||||||
```bash
|
```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
|
```bash
|
||||||
git add .
|
# 회원가입
|
||||||
git commit -m "수정 내용"
|
curl -X POST http://localhost:4000/auth/register \
|
||||||
git push
|
-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
|
```bash
|
||||||
# 코드 받기
|
# Pod 로그
|
||||||
git clone https://git.junggomoa.com/chpark/insurance.git
|
kubectl -n insurance logs -l app.kubernetes.io/name=insurance-api --tail=100
|
||||||
cd insurance
|
|
||||||
|
|
||||||
# 도커 이미지 빌드
|
# DB 접속
|
||||||
docker build -t git.junggomoa.com/chpark/insurance:latest .
|
kubectl -n insurance exec -it postgres-0 -- psql -U insurance
|
||||||
|
|
||||||
# 레지스트리 로그인 & push
|
# 마이그레이션 수동 실행
|
||||||
docker login git.junggomoa.com -u chpark -p <토큰>
|
kubectl -n insurance exec -it deploy/insurance-api -- npx prisma migrate deploy
|
||||||
docker push git.junggomoa.com/chpark/insurance:latest
|
|
||||||
|
|
||||||
# K8s 시크릿 & 매니페스트 적용
|
# 이미지 재배포
|
||||||
kubectl apply -f deploy/k8s/namespace.yaml
|
kubectl -n insurance rollout restart deployment/insurance-api deployment/insurance-web
|
||||||
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 버전 (선택)
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
FROM node:20-alpine AS builder
|
FROM node:20-alpine AS builder
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
ARG EXPO_PUBLIC_API_BASE=https://api.insurance.junggomoa.com
|
||||||
|
ENV EXPO_PUBLIC_API_BASE=${EXPO_PUBLIC_API_BASE}
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm ci --legacy-peer-deps --no-audit --no-fund
|
RUN npm ci --legacy-peer-deps --no-audit --no-fund
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,9 @@
|
|||||||
"resizeMode": "contain",
|
"resizeMode": "contain",
|
||||||
"backgroundColor": "#3B82F6"
|
"backgroundColor": "#3B82F6"
|
||||||
},
|
},
|
||||||
"assetBundlePatterns": ["**/*"],
|
"assetBundlePatterns": [
|
||||||
|
"**/*"
|
||||||
|
],
|
||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": true,
|
"supportsTablet": true,
|
||||||
"bundleIdentifier": "com.insurancecare.app",
|
"bundleIdentifier": "com.insurancecare.app",
|
||||||
@@ -38,7 +40,8 @@
|
|||||||
"photosPermission": "보험금 청구 서류 첨부를 위해 사진 접근이 필요합니다."
|
"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",
|
"@react-navigation/native-stack": "^6.9.26",
|
||||||
"expo": "~51.0.0",
|
"expo": "~51.0.0",
|
||||||
"expo-device": "~6.0.2",
|
"expo-device": "~6.0.2",
|
||||||
"expo-font": "^55.0.6",
|
"expo-font": "~12.0.10",
|
||||||
"expo-image-picker": "~15.0.5",
|
"expo-image-picker": "~15.1.0",
|
||||||
"expo-linear-gradient": "~13.0.2",
|
"expo-linear-gradient": "~13.0.2",
|
||||||
"expo-notifications": "~0.28.0",
|
"expo-notifications": "~0.28.0",
|
||||||
"expo-status-bar": "~1.12.1",
|
"expo-status-bar": "~1.12.1",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"react-dom": "^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-chart-kit": "^6.12.0",
|
||||||
"react-native-gesture-handler": "~2.16.1",
|
"react-native-gesture-handler": "~2.16.1",
|
||||||
"react-native-reanimated": "~3.10.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 React from 'react';
|
||||||
import { View, Text, StyleSheet } from 'react-native';
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
import Svg, { Circle } from 'react-native-svg';
|
|
||||||
import { colors } from '@/theme/colors';
|
import { colors } from '@/theme/colors';
|
||||||
import { typography } from '@/theme/typography';
|
import { typography } from '@/theme/typography';
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: number; // 0-100
|
value: number;
|
||||||
size?: number;
|
size?: number;
|
||||||
label?: string;
|
label?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function ScoreGauge({ value, size = 180, label = '내 보험 점수' }: Props) {
|
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 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 color = pct >= 80 ? colors.success : pct >= 60 ? colors.accent : colors.danger;
|
||||||
|
const stroke = 14;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }}>
|
<View style={{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }}>
|
||||||
<Svg width={size} height={size}>
|
<View
|
||||||
<Circle cx={size / 2} cy={size / 2} r={radius} stroke={colors.surfaceAlt} strokeWidth={stroke} fill="none" />
|
style={[
|
||||||
<Circle
|
styles.track,
|
||||||
cx={size / 2}
|
{
|
||||||
cy={size / 2}
|
width: size,
|
||||||
r={radius}
|
height: size,
|
||||||
stroke={color}
|
borderRadius: size / 2,
|
||||||
strokeWidth={stroke}
|
borderWidth: stroke,
|
||||||
fill="none"
|
borderColor: colors.surfaceAlt,
|
||||||
strokeLinecap="round"
|
},
|
||||||
strokeDasharray={`${dash}, ${circ}`}
|
]}
|
||||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
/>
|
||||||
|
<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` }],
|
||||||
|
},
|
||||||
|
]}
|
||||||
/>
|
/>
|
||||||
</Svg>
|
|
||||||
<View style={styles.center}>
|
<View style={styles.center}>
|
||||||
<Text style={styles.label}>{label}</Text>
|
<Text style={styles.label}>{label}</Text>
|
||||||
<Text style={[styles.value, { color }]}>{pct}</Text>
|
<Text style={[styles.value, { color }]}>{pct}</Text>
|
||||||
@@ -44,11 +54,9 @@ export default function ScoreGauge({ value, size = 180, label = '내 보험 점
|
|||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
center: {
|
track: { position: 'absolute' },
|
||||||
position: 'absolute',
|
fill: { position: 'absolute' },
|
||||||
alignItems: 'center',
|
center: { position: 'absolute', alignItems: 'center', justifyContent: 'center' },
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
label: { ...typography.caption, color: colors.textSecondary },
|
label: { ...typography.caption, color: colors.textSecondary },
|
||||||
value: { fontSize: 48, fontWeight: '800', marginTop: 2 },
|
value: { fontSize: 48, fontWeight: '800', marginTop: 2 },
|
||||||
unit: { ...typography.small, color: colors.textTertiary },
|
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 { createNativeStackNavigator } from '@react-navigation/native-stack';
|
||||||
import BottomTabs from './BottomTabs';
|
import BottomTabs from './BottomTabs';
|
||||||
|
import LoginScreen from '@/screens/auth/LoginScreen';
|
||||||
|
import RegisterScreen from '@/screens/auth/RegisterScreen';
|
||||||
import DiagnosisScreen from '@/screens/DiagnosisScreen';
|
import DiagnosisScreen from '@/screens/DiagnosisScreen';
|
||||||
import AnalysisScreen from '@/screens/AnalysisScreen';
|
import AnalysisScreen from '@/screens/AnalysisScreen';
|
||||||
import ScoreScreen from '@/screens/ScoreScreen';
|
import ScoreScreen from '@/screens/ScoreScreen';
|
||||||
@@ -15,8 +18,12 @@ import AIJudgeScreen from '@/screens/AIJudgeScreen';
|
|||||||
import PremiumDietScreen from '@/screens/PremiumDietScreen';
|
import PremiumDietScreen from '@/screens/PremiumDietScreen';
|
||||||
import SilsonGenScreen from '@/screens/SilsonGenScreen';
|
import SilsonGenScreen from '@/screens/SilsonGenScreen';
|
||||||
import NotificationScreen from '@/screens/NotificationScreen';
|
import NotificationScreen from '@/screens/NotificationScreen';
|
||||||
|
import { useAuthStore } from '@/store/useAuthStore';
|
||||||
|
import { colors } from '@/theme/colors';
|
||||||
|
|
||||||
export type RootStackParamList = {
|
export type RootStackParamList = {
|
||||||
|
Login: undefined;
|
||||||
|
Register: undefined;
|
||||||
Tabs: undefined;
|
Tabs: undefined;
|
||||||
Diagnosis: undefined;
|
Diagnosis: undefined;
|
||||||
Analysis: undefined;
|
Analysis: undefined;
|
||||||
@@ -37,8 +44,24 @@ export type RootStackParamList = {
|
|||||||
const Stack = createNativeStackNavigator<RootStackParamList>();
|
const Stack = createNativeStackNavigator<RootStackParamList>();
|
||||||
|
|
||||||
export default function RootNavigator() {
|
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 (
|
return (
|
||||||
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
<Stack.Navigator screenOptions={{ headerShown: false }}>
|
||||||
|
{user ? (
|
||||||
|
<>
|
||||||
<Stack.Screen name="Tabs" component={BottomTabs} />
|
<Stack.Screen name="Tabs" component={BottomTabs} />
|
||||||
<Stack.Screen name="Diagnosis" component={DiagnosisScreen} />
|
<Stack.Screen name="Diagnosis" component={DiagnosisScreen} />
|
||||||
<Stack.Screen name="Analysis" component={AnalysisScreen} />
|
<Stack.Screen name="Analysis" component={AnalysisScreen} />
|
||||||
@@ -54,6 +77,13 @@ export default function RootNavigator() {
|
|||||||
<Stack.Screen name="PremiumDiet" component={PremiumDietScreen} />
|
<Stack.Screen name="PremiumDiet" component={PremiumDietScreen} />
|
||||||
<Stack.Screen name="SilsonGen" component={SilsonGenScreen} />
|
<Stack.Screen name="SilsonGen" component={SilsonGenScreen} />
|
||||||
<Stack.Screen name="Notifications" component={NotificationScreen} />
|
<Stack.Screen name="Notifications" component={NotificationScreen} />
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Stack.Screen name="Login" component={LoginScreen} />
|
||||||
|
<Stack.Screen name="Register" component={RegisterScreen} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { View, Text, StyleSheet, TouchableOpacity, Dimensions } from 'react-native';
|
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
|
||||||
import { BarChart } from 'react-native-chart-kit';
|
|
||||||
import ScreenContainer from '@/components/ScreenContainer';
|
import ScreenContainer from '@/components/ScreenContainer';
|
||||||
import Header from '@/components/Header';
|
import Header from '@/components/Header';
|
||||||
import Card from '@/components/Card';
|
import Card from '@/components/Card';
|
||||||
import Section from '@/components/Section';
|
import Section from '@/components/Section';
|
||||||
import Badge from '@/components/Badge';
|
import Badge from '@/components/Badge';
|
||||||
|
import BarChartSimple from '@/components/BarChartSimple';
|
||||||
import { colors } from '@/theme/colors';
|
import { colors } from '@/theme/colors';
|
||||||
import { radius, spacing, typography } from '@/theme/typography';
|
import { radius, spacing, typography } from '@/theme/typography';
|
||||||
|
|
||||||
const screenWidth = Dimensions.get('window').width;
|
|
||||||
|
|
||||||
const data = {
|
const data = {
|
||||||
'20대': { prem: 18, must: ['실손', '상해'], rec: ['암 (가족력)'], avgCoverage: '2,000만원' },
|
'20대': { prem: 18, must: ['실손', '상해'], rec: ['암 (가족력)'], avgCoverage: '2,000만원' },
|
||||||
'30대': { prem: 28, must: ['실손', '암', '종신'], rec: ['여성특화', '치아'], avgCoverage: '5,000만원' },
|
'30대': { prem: 28, must: ['실손', '암', '종신'], rec: ['여성특화', '치아'], avgCoverage: '5,000만원' },
|
||||||
@@ -79,26 +77,11 @@ export default function AnalysisScreen() {
|
|||||||
|
|
||||||
<Section title="연령별 평균 월 보험료">
|
<Section title="연령별 평균 월 보험료">
|
||||||
<Card padding="md">
|
<Card padding="md">
|
||||||
<BarChart
|
<BarChartSimple
|
||||||
data={{
|
data={ages.map((a) => ({ label: a, value: data[a].prem }))}
|
||||||
labels: ages as unknown as string[],
|
unit="만"
|
||||||
datasets: [{ data: ages.map((a) => data[a].prem) }],
|
barColor={colors.primary}
|
||||||
}}
|
height={200}
|
||||||
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 }}
|
|
||||||
/>
|
/>
|
||||||
</Card>
|
</Card>
|
||||||
</Section>
|
</Section>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { View, Text, StyleSheet } from 'react-native';
|
import { View, Text, StyleSheet, RefreshControl, ScrollView } from 'react-native';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
@@ -9,21 +9,44 @@ import Card from '@/components/Card';
|
|||||||
import Section from '@/components/Section';
|
import Section from '@/components/Section';
|
||||||
import Badge from '@/components/Badge';
|
import Badge from '@/components/Badge';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import { useAppStore } from '@/store/useAppStore';
|
import { useDataStore } from '@/store/useDataStore';
|
||||||
import { colors } from '@/theme/colors';
|
import { colors } from '@/theme/colors';
|
||||||
import { spacing, typography } from '@/theme/typography';
|
import { spacing, typography } from '@/theme/typography';
|
||||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||||
|
|
||||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
||||||
|
const statusLabel: Record<string, string> = {
|
||||||
|
SUBMITTED: '접수',
|
||||||
|
REVIEWING: '심사',
|
||||||
|
ADDITIONAL_DOCS: '서류보완',
|
||||||
|
APPROVED: '승인',
|
||||||
|
PAID: '지급완료',
|
||||||
|
REJECTED: '거절',
|
||||||
|
};
|
||||||
|
|
||||||
export default function ClaimHubScreen() {
|
export default function ClaimHubScreen() {
|
||||||
const nav = useNavigation<Nav>();
|
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 (
|
return (
|
||||||
<ScreenContainer>
|
<ScreenContainer scroll={false}>
|
||||||
<Header title="보험금" showBack={false} />
|
<Header title="보험금" showBack={false} />
|
||||||
|
<ScrollView
|
||||||
|
contentContainerStyle={{ paddingBottom: 40 }}
|
||||||
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
||||||
|
>
|
||||||
<View style={{ padding: spacing.lg }}>
|
<View style={{ padding: spacing.lg }}>
|
||||||
<Card>
|
<Card>
|
||||||
<Text style={styles.h1}>📸 보험금, 앱에서 바로 청구</Text>
|
<Text style={styles.h1}>📸 보험금, 앱에서 바로 청구</Text>
|
||||||
@@ -36,23 +59,37 @@ export default function ClaimHubScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Section title="진행 상태">
|
<Section title="진행 상태">
|
||||||
{claims.map((c) => (
|
{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 }}>
|
<Card key={c.id} style={{ marginBottom: 10 }}>
|
||||||
<View style={{ flexDirection: 'row' }}>
|
<View style={{ flexDirection: 'row' }}>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Badge
|
<Badge label={statusLabel[c.status] ?? c.status} tone={tone} />
|
||||||
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={[typography.bodyBold as any, { marginTop: 6 }]}>{c.title}</Text>
|
||||||
<Text style={styles.dim}>{c.date}</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>
|
</View>
|
||||||
{c.amount ? <Text style={styles.amt}>{c.amount.toLocaleString()}원</Text> : null}
|
{c.amount ? <Text style={styles.amt}>{c.amount.toLocaleString()}원</Text> : null}
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="보험금 관련 바로가기">
|
<Section title="보험금 관련 바로가기">
|
||||||
@@ -89,6 +126,7 @@ export default function ClaimHubScreen() {
|
|||||||
</Card>
|
</Card>
|
||||||
</View>
|
</View>
|
||||||
</Section>
|
</Section>
|
||||||
|
</ScrollView>
|
||||||
</ScreenContainer>
|
</ScreenContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-109
@@ -1,5 +1,6 @@
|
|||||||
import React, { useState } from 'react';
|
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 * as ImagePicker from 'expo-image-picker';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import ScreenContainer from '@/components/ScreenContainer';
|
import ScreenContainer from '@/components/ScreenContainer';
|
||||||
@@ -8,38 +9,34 @@ import Card from '@/components/Card';
|
|||||||
import Section from '@/components/Section';
|
import Section from '@/components/Section';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import Badge from '@/components/Badge';
|
import Badge from '@/components/Badge';
|
||||||
|
import { claimApi } from '@/api/endpoints';
|
||||||
|
import { useDataStore } from '@/store/useDataStore';
|
||||||
import { colors } from '@/theme/colors';
|
import { colors } from '@/theme/colors';
|
||||||
import { radius, spacing, typography } from '@/theme/typography';
|
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 }> = {
|
const docLabels: Record<DocType, { title: string; desc: string; icon: keyof typeof Ionicons.glyphMap }> = {
|
||||||
receipt: { title: '영수증', desc: '진료비 영수증 원본', icon: 'receipt' },
|
RECEIPT: { title: '영수증', desc: '진료비 영수증 원본', icon: 'receipt' },
|
||||||
diagnosis: { title: '진단서', desc: '병원 발급 진단서', icon: 'document-text' },
|
DIAGNOSIS: { title: '진단서', desc: '병원 발급 진단서', icon: 'document-text' },
|
||||||
detail: { title: '세부내역서', desc: '비급여 항목 포함', icon: 'list' },
|
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() {
|
export default function ClaimScreen() {
|
||||||
|
const nav = useNavigation<any>();
|
||||||
|
const fetchClaims = useDataStore((s) => s.fetchClaims);
|
||||||
const [docs, setDocs] = useState<Record<DocType, string | null>>({
|
const [docs, setDocs] = useState<Record<DocType, string | null>>({
|
||||||
receipt: null,
|
RECEIPT: null,
|
||||||
diagnosis: null,
|
DIAGNOSIS: null,
|
||||||
detail: null,
|
DETAIL: null,
|
||||||
});
|
});
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
const [hospital, setHospital] = 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 pick = async (type: DocType) => {
|
||||||
const res = await ImagePicker.launchImageLibraryAsync({
|
const res = await ImagePicker.launchImageLibraryAsync({ quality: 0.7 });
|
||||||
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
|
||||||
quality: 0.7,
|
|
||||||
});
|
|
||||||
if (!res.canceled && res.assets[0]) {
|
if (!res.canceled && res.assets[0]) {
|
||||||
setDocs({ ...docs, [type]: res.assets[0].uri });
|
setDocs({ ...docs, [type]: res.assets[0].uri });
|
||||||
}
|
}
|
||||||
@@ -47,59 +44,55 @@ export default function ClaimScreen() {
|
|||||||
|
|
||||||
const capture = async (type: DocType) => {
|
const capture = async (type: DocType) => {
|
||||||
const perm = await ImagePicker.requestCameraPermissionsAsync();
|
const perm = await ImagePicker.requestCameraPermissionsAsync();
|
||||||
if (!perm.granted) {
|
if (!perm.granted) return Alert.alert('카메라 권한이 필요합니다.');
|
||||||
Alert.alert('카메라 권한이 필요합니다.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const res = await ImagePicker.launchCameraAsync({ quality: 0.7 });
|
const res = await ImagePicker.launchCameraAsync({ quality: 0.7 });
|
||||||
if (!res.canceled && res.assets[0]) {
|
if (!res.canceled && res.assets[0]) {
|
||||||
setDocs({ ...docs, [type]: res.assets[0].uri });
|
setDocs({ ...docs, [type]: res.assets[0].uri });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const submit = () => {
|
const submit = async () => {
|
||||||
if (!docs.receipt) {
|
if (!title) return Alert.alert('청구 제목을 입력하세요');
|
||||||
Alert.alert('영수증은 필수입니다.');
|
if (!docs.RECEIPT) return Alert.alert('영수증은 필수입니다');
|
||||||
return;
|
|
||||||
|
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 (
|
return (
|
||||||
<ScreenContainer>
|
<ScreenContainer scroll={false}>
|
||||||
<Header title="보험금 청구" />
|
<Header title="보험금 청구" />
|
||||||
<View style={{ padding: spacing.lg }}>
|
<ScrollView contentContainerStyle={{ padding: spacing.lg, paddingBottom: 40 }}>
|
||||||
<Card>
|
<Card>
|
||||||
<Text style={typography.bodyBold as any}>📸 앱에서 바로 청구</Text>
|
<Text style={typography.bodyBold as any}>📸 서류 업로드</Text>
|
||||||
<View style={{ marginTop: 12, gap: 10 }}>
|
<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) => {
|
{(Object.keys(docLabels) as DocType[]).map((t) => {
|
||||||
const info = docLabels[t];
|
const info = docLabels[t];
|
||||||
const uri = docs[t];
|
const uri = docs[t];
|
||||||
return (
|
return (
|
||||||
<Card key={t} style={{ marginBottom: 10 }}>
|
<View key={t} style={styles.docRow}>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
|
||||||
<View style={styles.docIcon}>
|
<View style={styles.docIcon}>
|
||||||
<Ionicons name={info.icon} size={22} color={colors.primary} />
|
<Ionicons name={info.icon} size={22} color={colors.primary} />
|
||||||
</View>
|
</View>
|
||||||
<View style={{ flex: 1, marginLeft: 12 }}>
|
<View style={{ flex: 1, marginLeft: 12 }}>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||||
<Text style={typography.bodyBold as any}>{info.title}</Text>
|
<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>
|
</View>
|
||||||
<Text style={{ ...typography.caption, color: colors.textSecondary } as any}>{info.desc}</Text>
|
<Text style={{ ...typography.caption, color: colors.textSecondary } as any}>{info.desc}</Text>
|
||||||
</View>
|
</View>
|
||||||
@@ -118,80 +111,36 @@ export default function ClaimScreen() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Section>
|
</View>
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Section title="청구 정보">
|
<Section title="청구 정보">
|
||||||
<Card>
|
<Card>
|
||||||
<Text style={styles.label}>병원명</Text>
|
<Text style={styles.label}>청구 제목 *</Text>
|
||||||
<Text style={styles.inputView}>{hospital || '병원명 입력'}</Text>
|
<TextInput style={styles.input} placeholder="예) 발목 염좌 정형외과" value={title} onChangeText={setTitle} placeholderTextColor={colors.textTertiary} />
|
||||||
<View style={{ flexDirection: 'row', gap: 6, flexWrap: 'wrap', marginTop: 4 }}>
|
<Text style={[styles.label, { marginTop: 12 }]}>병원명</Text>
|
||||||
{['강남세브란스', '서울아산병원', '삼성의료원', '고려대병원'].map((h) => (
|
<TextInput style={styles.input} placeholder="예) 강남세브란스" value={hospital} onChangeText={setHospital} placeholderTextColor={colors.textTertiary} />
|
||||||
<TouchableOpacity key={h} style={styles.pill} onPress={() => setHospital(h)}>
|
|
||||||
<Text style={styles.pillText}>{h}</Text>
|
|
||||||
</TouchableOpacity>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
<Text style={[styles.label, { marginTop: 12 }]}>진료일자</Text>
|
<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>
|
</Card>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<View style={{ paddingTop: 16, gap: 8 }}>
|
<View style={{ marginTop: 16, gap: 8 }}>
|
||||||
<Button title="보험금 청구하기" size="lg" onPress={submit} />
|
<Button title="보험금 청구하기" size="lg" onPress={submit} loading={submitting} />
|
||||||
<Button title="AI 판정으로 예상 금액 확인" variant="outline" onPress={() => {}} />
|
<Button title="AI 판정으로 예상 금액 확인" variant="outline" onPress={() => nav.navigate('AIJudge')} />
|
||||||
</View>
|
|
||||||
</View>
|
</View>
|
||||||
|
</ScrollView>
|
||||||
</ScreenContainer>
|
</ScreenContainer>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const styles = StyleSheet.create({
|
const styles = StyleSheet.create({
|
||||||
stepRow: { flexDirection: 'row', alignItems: 'center' },
|
docRow: { flexDirection: 'row', alignItems: 'center' },
|
||||||
stepCircle: {
|
docIcon: { width: 44, height: 44, borderRadius: 22, backgroundColor: colors.primaryLight, alignItems: 'center', justifyContent: 'center' },
|
||||||
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',
|
|
||||||
},
|
|
||||||
thumb: { width: 50, height: 50, borderRadius: 8 },
|
thumb: { width: 50, height: 50, borderRadius: 8 },
|
||||||
smallBtn: {
|
smallBtn: { width: 38, height: 38, borderRadius: 19, borderWidth: 1, borderColor: colors.primary, alignItems: 'center', justifyContent: 'center' },
|
||||||
width: 38,
|
|
||||||
height: 38,
|
|
||||||
borderRadius: 19,
|
|
||||||
borderWidth: 1,
|
|
||||||
borderColor: colors.primary,
|
|
||||||
alignItems: 'center',
|
|
||||||
justifyContent: 'center',
|
|
||||||
},
|
|
||||||
label: { ...typography.caption, color: colors.textSecondary, fontWeight: '600' },
|
label: { ...typography.caption, color: colors.textSecondary, fontWeight: '600' },
|
||||||
inputView: {
|
input: { borderWidth: 1, borderColor: colors.border, borderRadius: radius.md, padding: 12, marginTop: 6, color: colors.text, backgroundColor: colors.surface },
|
||||||
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 },
|
|
||||||
});
|
});
|
||||||
|
|||||||
+137
-41
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { View, Text, StyleSheet } from 'react-native';
|
import { View, Text, StyleSheet, ScrollView, RefreshControl, TextInput, TouchableOpacity, Alert, Modal } from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import ScreenContainer from '@/components/ScreenContainer';
|
import ScreenContainer from '@/components/ScreenContainer';
|
||||||
import Header from '@/components/Header';
|
import Header from '@/components/Header';
|
||||||
@@ -8,43 +8,61 @@ import Section from '@/components/Section';
|
|||||||
import Badge from '@/components/Badge';
|
import Badge from '@/components/Badge';
|
||||||
import ProgressBar from '@/components/ProgressBar';
|
import ProgressBar from '@/components/ProgressBar';
|
||||||
import Button from '@/components/Button';
|
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 { colors } from '@/theme/colors';
|
||||||
import { radius, spacing, typography } from '@/theme/typography';
|
import { radius, spacing, typography } from '@/theme/typography';
|
||||||
|
|
||||||
const relationIcon: Record<FamilyMember['relation'], keyof typeof Ionicons.glyphMap> = {
|
const relationIcon: Record<FamilyMember['relation'], keyof typeof Ionicons.glyphMap> = {
|
||||||
본인: 'person',
|
SELF: 'person',
|
||||||
배우자: 'heart',
|
SPOUSE: 'heart',
|
||||||
자녀: 'happy',
|
CHILD: 'happy',
|
||||||
부모: 'people',
|
PARENT: 'people',
|
||||||
형제: 'people-outline',
|
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() {
|
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 analyze = (m: FamilyMember) => {
|
||||||
const required = essentialMap[m.relation] ?? [];
|
const required = essentialMap[m.relation] ?? [];
|
||||||
const have = new Set(m.policies.map((p) => p.type));
|
const have = new Set(m.policies.map((p) => p.type));
|
||||||
const missing = required.filter((r) => !have.has(r as any));
|
const missing = required.filter((r) => !have.has(r));
|
||||||
const covered = required.length - missing.length;
|
const score = required.length === 0 ? 100 : Math.round(((required.length - missing.length) / required.length) * 100);
|
||||||
const score = required.length === 0 ? 100 : Math.round((covered / required.length) * 100);
|
return { required, missing, score };
|
||||||
return { required, missing, covered, 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 (
|
return (
|
||||||
<ScreenContainer>
|
<>
|
||||||
|
<ScreenContainer scroll={false}>
|
||||||
<Header title="가족 보험" />
|
<Header title="가족 보험" />
|
||||||
<View style={{ padding: spacing.lg }}>
|
<ScrollView
|
||||||
|
contentContainerStyle={{ padding: spacing.lg, paddingBottom: 40 }}
|
||||||
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={async () => { setRefreshing(true); await fetchFamily(); setRefreshing(false); }} />}
|
||||||
|
>
|
||||||
<Card>
|
<Card>
|
||||||
<Text style={typography.caption as any}>👨👩👧 우리 가족 총 보장 점수</Text>
|
<Text style={typography.caption as any}>👨👩👧 우리 가족 총 보장 점수</Text>
|
||||||
<Text style={{ ...typography.h1 as any, color: colors.primary, marginTop: 6 }}>{avgScore}점</Text>
|
<Text style={{ ...typography.h1 as any, color: colors.primary, marginTop: 6 }}>{avgScore}점</Text>
|
||||||
@@ -55,7 +73,19 @@ export default function FamilyScreen() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Section title="가족 구성원">
|
<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) => {
|
{family.map((m) => {
|
||||||
const a = analyze(m);
|
const a = analyze(m);
|
||||||
return (
|
return (
|
||||||
@@ -67,25 +97,39 @@ export default function FamilyScreen() {
|
|||||||
<View style={{ flex: 1, marginLeft: 12 }}>
|
<View style={{ flex: 1, marginLeft: 12 }}>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||||
<Text style={typography.bodyBold as any}>{m.name}</Text>
|
<Text style={typography.bodyBold as any}>{m.name}</Text>
|
||||||
<Badge label={m.relation} tone="primary" style={{ marginLeft: 6 }} />
|
<Badge label={relationLabel[m.relation]} tone="primary" style={{ marginLeft: 6 }} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={{ ...typography.caption, color: colors.textSecondary } as any}>
|
<Text style={{ ...typography.caption, color: colors.textSecondary } as any}>
|
||||||
{m.age}세 · {m.gender}
|
{m.age}세 · {m.gender === 'MALE' ? '남' : '여'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={{ ...typography.h3 as any, color: scoreColor(a.score) }}>{a.score}점</Text>
|
<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>
|
||||||
|
|
||||||
<View style={styles.sep} />
|
<View style={styles.sep} />
|
||||||
<View style={{ gap: 6 }}>
|
<View style={{ gap: 6 }}>
|
||||||
|
{m.policies.length === 0 && (
|
||||||
|
<Text style={styles.dim}>보험 없음</Text>
|
||||||
|
)}
|
||||||
{m.policies.map((p) => (
|
{m.policies.map((p) => (
|
||||||
<View key={p.id} style={styles.policyRow}>
|
<View key={p.id} style={styles.policyRow}>
|
||||||
<Ionicons name="checkmark-circle" size={16} color={colors.success} />
|
<Ionicons name="checkmark-circle" size={16} color={colors.success} />
|
||||||
<Text style={{ ...typography.body, marginLeft: 6, flex: 1 } as any} numberOfLines={1}>
|
<Text style={{ ...typography.body, marginLeft: 6, flex: 1 } as any} numberOfLines={1}>
|
||||||
{p.type} · {p.name}
|
{p.name}
|
||||||
</Text>
|
</Text>
|
||||||
<Text style={{ ...typography.small, color: colors.textSecondary } as any}>
|
<Text style={{ ...typography.small, color: colors.textSecondary } as any}>
|
||||||
{(p.coverage / 10000).toLocaleString()}만
|
{Math.round(p.coverage / 10000).toLocaleString()}만
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
))}
|
))}
|
||||||
@@ -102,22 +146,66 @@ export default function FamilyScreen() {
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</Section>
|
</Section>
|
||||||
|
</ScrollView>
|
||||||
<Section title="💡 가족 보험 TIP">
|
|
||||||
<Card>
|
|
||||||
<Text style={typography.body as any}>
|
|
||||||
• 자녀는 어릴수록 어린이보험 가입 유리{'\n'}
|
|
||||||
• 65세 이상은 유병자/간편 실손 검토{'\n'}
|
|
||||||
• 부부 동시 가입 시 할인 혜택 있는 상품 다수
|
|
||||||
</Text>
|
|
||||||
</Card>
|
|
||||||
</Section>
|
|
||||||
|
|
||||||
<View style={{ paddingTop: 16 }}>
|
|
||||||
<Button title="가족 구성원 추가" variant="outline" />
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</ScreenContainer>
|
</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 },
|
sep: { height: 1, backgroundColor: colors.border, marginVertical: 12 },
|
||||||
policyRow: { flexDirection: 'row', alignItems: 'center' },
|
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 React, { useEffect } from 'react';
|
||||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-native';
|
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, RefreshControl } from 'react-native';
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
@@ -12,21 +12,42 @@ import ProgressBar from '@/components/ProgressBar';
|
|||||||
import Badge from '@/components/Badge';
|
import Badge from '@/components/Badge';
|
||||||
import { colors } from '@/theme/colors';
|
import { colors } from '@/theme/colors';
|
||||||
import { radius, shadow, spacing, typography } from '@/theme/typography';
|
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';
|
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||||
|
|
||||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||||
|
|
||||||
export default function HomeScreen() {
|
export default function HomeScreen() {
|
||||||
const nav = useNavigation<Nav>();
|
const nav = useNavigation<Nav>();
|
||||||
const profile = useAppStore((s) => s.profile);
|
const user = useAuthStore((s) => s.user);
|
||||||
const score = useAppStore((s) => s.score);
|
const { score, notifications, upcoming, fetchAll } = useDataStore();
|
||||||
const hiddenMoney = useAppStore((s) => s.hiddenMoney);
|
const [refreshing, setRefreshing] = React.useState(false);
|
||||||
const notifications = useAppStore((s) => s.notifications);
|
|
||||||
|
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 (
|
return (
|
||||||
<SafeAreaView style={styles.safe} edges={['top']}>
|
<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 style={styles.topBar}>
|
||||||
<View>
|
<View>
|
||||||
<Text style={styles.brand}>보험케어</Text>
|
<Text style={styles.brand}>보험케어</Text>
|
||||||
@@ -49,16 +70,16 @@ export default function HomeScreen() {
|
|||||||
end={{ x: 1, y: 1 }}
|
end={{ x: 1, y: 1 }}
|
||||||
style={styles.hero}
|
style={styles.hero}
|
||||||
>
|
>
|
||||||
<Text style={styles.heroHello}>{profile.name}님, 안녕하세요 👋</Text>
|
<Text style={styles.heroHello}>{user?.name ?? '사용자'}님, 안녕하세요 👋</Text>
|
||||||
<View style={styles.heroRow}>
|
<View style={styles.heroRow}>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={styles.heroLabel}>내 보험 점수</Text>
|
<Text style={styles.heroLabel}>내 보험 점수</Text>
|
||||||
<Text style={styles.heroScore}>
|
<Text style={styles.heroScore}>
|
||||||
{score.total}
|
{totalScore}
|
||||||
<Text style={styles.heroScoreUnit}> /100</Text>
|
<Text style={styles.heroScoreUnit}> /100</Text>
|
||||||
</Text>
|
</Text>
|
||||||
<View style={{ height: 10 }} />
|
<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>
|
||||||
<View style={styles.heroBadge}>
|
<View style={styles.heroBadge}>
|
||||||
<Ionicons name="shield-checkmark" size={36} color="#FFF" />
|
<Ionicons name="shield-checkmark" size={36} color="#FFF" />
|
||||||
@@ -92,7 +113,7 @@ export default function HomeScreen() {
|
|||||||
<View style={styles.grid}>
|
<View style={styles.grid}>
|
||||||
<IconTile icon="stats-chart" label="점수" color="#8B5CF6" bg="#EDE9FE" onPress={() => nav.navigate('Score')} />
|
<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="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="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="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')} />
|
<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')}>
|
<Card onPress={() => nav.navigate('HiddenMoney')}>
|
||||||
<View style={styles.rowBetween}>
|
<View style={styles.rowBetween}>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Badge label="못 받은 보험금 발견" tone="success" />
|
<Badge label="못 받은 보험금 조회 가능" tone="success" />
|
||||||
<Text style={[typography.h2 as any, { marginTop: 8, color: colors.success }]}>
|
<Text style={[typography.h2 as any, { marginTop: 8, color: colors.success }]}>조회하기</Text>
|
||||||
{(hiddenMoney.unclaimed + hiddenMoney.dormant).toLocaleString()}원
|
<Text style={styles.dim}>보험개발원 API 통합 조회</Text>
|
||||||
</Text>
|
|
||||||
<Text style={styles.dim}>미청구 {hiddenMoney.unclaimed.toLocaleString()}원 + 휴면 {hiddenMoney.dormant.toLocaleString()}원</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<Ionicons name="chevron-forward" size={22} color={colors.textTertiary} />
|
<Ionicons name="chevron-forward" size={22} color={colors.textTertiary} />
|
||||||
</View>
|
</View>
|
||||||
@@ -122,13 +141,31 @@ export default function HomeScreen() {
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="🔔 다가오는 알림">
|
<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')}>
|
<Card key={n.id} style={{ marginBottom: 10 }} onPress={() => nav.navigate('Notifications')}>
|
||||||
<View style={styles.rowBetween}>
|
<View style={styles.rowBetween}>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Badge
|
<Badge
|
||||||
label={n.tone === 'danger' ? '긴급' : n.tone === 'warn' ? '알림' : '안내'}
|
label={n.tone === 'DANGER' ? '긴급' : n.tone === 'WARN' ? '알림' : '안내'}
|
||||||
tone={n.tone === 'danger' ? 'danger' : n.tone === 'warn' ? 'warning' : 'primary'}
|
tone={n.tone === 'DANGER' ? 'danger' : n.tone === 'WARN' ? 'warning' : 'primary'}
|
||||||
/>
|
/>
|
||||||
<Text style={[typography.bodyBold as any, { marginTop: 6 }]}>{n.title}</Text>
|
<Text style={[typography.bodyBold as any, { marginTop: 6 }]}>{n.title}</Text>
|
||||||
<Text style={styles.dim}>{n.body}</Text>
|
<Text style={styles.dim}>{n.body}</Text>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { View, Text, StyleSheet } from 'react-native';
|
import { View, Text, StyleSheet, RefreshControl, ScrollView, Alert, TextInput, TouchableOpacity, Modal } from 'react-native';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
@@ -9,24 +9,75 @@ import Card from '@/components/Card';
|
|||||||
import Section from '@/components/Section';
|
import Section from '@/components/Section';
|
||||||
import Badge from '@/components/Badge';
|
import Badge from '@/components/Badge';
|
||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import { useAppStore } from '@/store/useAppStore';
|
import { useDataStore } from '@/store/useDataStore';
|
||||||
import { colors } from '@/theme/colors';
|
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 type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||||
|
import { policyApi, type Policy } from '@/api/endpoints';
|
||||||
|
|
||||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
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() {
|
export default function MyInsuranceScreen() {
|
||||||
const nav = useNavigation<Nav>();
|
const nav = useNavigation<Nav>();
|
||||||
const family = useAppStore((s) => s.family);
|
const { policies, fetchPolicies, fetchScore } = useDataStore();
|
||||||
const profile = useAppStore((s) => s.profile);
|
const [refreshing, setRefreshing] = useState(false);
|
||||||
const me = family.find((m) => m.relation === '본인');
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const totalMonthly = me?.policies.reduce((a, p) => a + p.monthlyPremium, 0) ?? 0;
|
|
||||||
const totalCoverage = me?.policies.reduce((a, p) => a + p.coverage, 0) ?? 0;
|
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 (
|
return (
|
||||||
<ScreenContainer>
|
<>
|
||||||
|
<ScreenContainer scroll={false}>
|
||||||
<Header title="내 보험" showBack={false} />
|
<Header title="내 보험" showBack={false} />
|
||||||
|
<ScrollView
|
||||||
|
style={{ flex: 1 }}
|
||||||
|
contentContainerStyle={{ paddingBottom: 40 }}
|
||||||
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
||||||
|
>
|
||||||
<Card style={{ margin: spacing.lg }}>
|
<Card style={{ margin: spacing.lg }}>
|
||||||
<Text style={styles.label}>이달 납입 보험료</Text>
|
<Text style={styles.label}>이달 납입 보험료</Text>
|
||||||
<Text style={styles.big}>{totalMonthly.toLocaleString()}원</Text>
|
<Text style={styles.big}>{totalMonthly.toLocaleString()}원</Text>
|
||||||
@@ -34,17 +85,22 @@ export default function MyInsuranceScreen() {
|
|||||||
<View style={{ flexDirection: 'row' }}>
|
<View style={{ flexDirection: 'row' }}>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={styles.label}>총 보장금액</Text>
|
<Text style={styles.label}>총 보장금액</Text>
|
||||||
<Text style={styles.mid}>{(totalCoverage / 10000).toLocaleString()}만원</Text>
|
<Text style={styles.mid}>{Math.round(totalCoverage / 10000).toLocaleString()}만원</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={styles.label}>가입 보험</Text>
|
<Text style={styles.label}>가입 보험</Text>
|
||||||
<Text style={styles.mid}>{me?.policies.length ?? 0}건</Text>
|
<Text style={styles.mid}>{own.length}건</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Section title="내 보험 목록">
|
<Section title="내 보험 목록" right={<TouchableOpacity onPress={() => setModalOpen(true)}><Text style={styles.addBtn}>+ 추가</Text></TouchableOpacity>}>
|
||||||
{me?.policies.map((p) => (
|
{own.length === 0 && (
|
||||||
|
<Card>
|
||||||
|
<Text style={styles.dim}>아직 등록된 보험이 없습니다. + 추가 버튼으로 등록해 주세요.</Text>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{own.map((p) => (
|
||||||
<Card key={p.id} style={{ marginBottom: 10 }}>
|
<Card key={p.id} style={{ marginBottom: 10 }}>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||||
<View style={styles.icon}>
|
<View style={styles.icon}>
|
||||||
@@ -52,15 +108,17 @@ export default function MyInsuranceScreen() {
|
|||||||
</View>
|
</View>
|
||||||
<View style={{ flex: 1, marginLeft: 12 }}>
|
<View style={{ flex: 1, marginLeft: 12 }}>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
|
||||||
<Badge label={p.type} tone="primary" />
|
<Badge label={POLICY_TYPES.find((t) => t.v === p.type)?.label ?? p.type} tone="primary" />
|
||||||
<Text style={styles.insurer}>{p.insurer}</Text>
|
<Text style={styles.insurer}>{p.insurer}</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.name}>{p.name}</Text>
|
<Text style={styles.name}>{p.name}</Text>
|
||||||
<Text style={styles.dim}>
|
<Text style={styles.dim}>
|
||||||
월 {p.monthlyPremium.toLocaleString()}원 · 보장 {(p.coverage / 10000).toLocaleString()}만원
|
월 {p.monthlyPremium.toLocaleString()}원 · 보장 {Math.round(p.coverage / 10000).toLocaleString()}만원
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
|
<TouchableOpacity onPress={() => onDelete(p.id)} hitSlop={10}>
|
||||||
|
<Ionicons name="trash-outline" size={20} color={colors.textTertiary} />
|
||||||
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
))}
|
||||||
@@ -74,7 +132,78 @@ export default function MyInsuranceScreen() {
|
|||||||
<Button title="가족 보험 한눈에 보기" variant="outline" onPress={() => nav.navigate('Family')} />
|
<Button title="가족 보험 한눈에 보기" variant="outline" onPress={() => nav.navigate('Family')} />
|
||||||
</View>
|
</View>
|
||||||
</Section>
|
</Section>
|
||||||
|
</ScrollView>
|
||||||
</ScreenContainer>
|
</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 },
|
insurer: { ...typography.caption, color: colors.textSecondary },
|
||||||
name: { ...typography.bodyBold, color: colors.text, marginTop: 2 },
|
name: { ...typography.bodyBold, color: colors.text, marginTop: 2 },
|
||||||
dim: { ...typography.caption, color: colors.textSecondary, 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 Card from '@/components/Card';
|
||||||
import Section from '@/components/Section';
|
import Section from '@/components/Section';
|
||||||
import Badge from '@/components/Badge';
|
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 { colors } from '@/theme/colors';
|
||||||
import { spacing, typography } from '@/theme/typography';
|
import { spacing, typography } from '@/theme/typography';
|
||||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||||
@@ -28,7 +29,8 @@ const menu = [
|
|||||||
|
|
||||||
export default function MyPageScreen() {
|
export default function MyPageScreen() {
|
||||||
const nav = useNavigation<Nav>();
|
const nav = useNavigation<Nav>();
|
||||||
const profile = useAppStore((s) => s.profile);
|
const user = useAuthStore((s) => s.user);
|
||||||
|
const logout = useAuthStore((s) => s.logout);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScreenContainer>
|
<ScreenContainer>
|
||||||
@@ -40,12 +42,13 @@ export default function MyPageScreen() {
|
|||||||
<Ionicons name="person" size={32} color={colors.primary} />
|
<Ionicons name="person" size={32} color={colors.primary} />
|
||||||
</View>
|
</View>
|
||||||
<View style={{ flex: 1, marginLeft: 14 }}>
|
<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}>
|
<Text style={styles.dim}>
|
||||||
{profile.age}세 · {profile.gender} · {profile.job}
|
{user?.profile?.age ?? '-'}세 · {user?.profile?.gender === 'FEMALE' ? '여' : '남'} · {user?.profile?.job ?? '-'}
|
||||||
</Text>
|
</Text>
|
||||||
<View style={{ marginTop: 6 }}>
|
<View style={{ marginTop: 6, flexDirection: 'row', gap: 6 }}>
|
||||||
<Badge label={`보험점수 ${profile.score}점`} tone="primary" />
|
<Badge label={`보험점수 ${user?.profile?.score ?? 0}점`} tone="primary" />
|
||||||
|
{user?.provider === 'KAKAO' && <Badge label="카카오" tone="warning" />}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
@@ -68,6 +71,9 @@ export default function MyPageScreen() {
|
|||||||
))}
|
))}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
<View style={{ paddingHorizontal: spacing.lg, marginTop: 16 }}>
|
||||||
|
<Button title="로그아웃" variant="outline" onPress={logout} />
|
||||||
|
</View>
|
||||||
<View style={{ alignItems: 'center', marginTop: 24 }}>
|
<View style={{ alignItems: 'center', marginTop: 24 }}>
|
||||||
<Text style={styles.dim}>보험케어 v1.0.0</Text>
|
<Text style={styles.dim}>보험케어 v1.0.0</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
+47
-20
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { View, Text, StyleSheet } from 'react-native';
|
import { View, Text, StyleSheet, RefreshControl, ScrollView } from 'react-native';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { useNavigation } from '@react-navigation/native';
|
import { useNavigation } from '@react-navigation/native';
|
||||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||||
@@ -10,7 +10,8 @@ import Section from '@/components/Section';
|
|||||||
import Button from '@/components/Button';
|
import Button from '@/components/Button';
|
||||||
import ScoreGauge from '@/components/ScoreGauge';
|
import ScoreGauge from '@/components/ScoreGauge';
|
||||||
import ProgressBar from '@/components/ProgressBar';
|
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 { colors } from '@/theme/colors';
|
||||||
import { spacing, typography } from '@/theme/typography';
|
import { spacing, typography } from '@/theme/typography';
|
||||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||||
@@ -26,26 +27,51 @@ const statusMap = {
|
|||||||
|
|
||||||
export default function ScoreScreen() {
|
export default function ScoreScreen() {
|
||||||
const nav = useNavigation<Nav>();
|
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 (
|
return (
|
||||||
<ScreenContainer>
|
<ScreenContainer scroll={false}>
|
||||||
<Header title="내 보험 점수" />
|
<Header title="내 보험 점수" />
|
||||||
<View style={{ padding: spacing.lg }}>
|
<ScrollView
|
||||||
|
contentContainerStyle={{ padding: spacing.lg, paddingBottom: 40 }}
|
||||||
|
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
|
||||||
|
>
|
||||||
<Card padding="xl">
|
<Card padding="xl">
|
||||||
<View style={{ alignItems: 'center' }}>
|
<View style={{ alignItems: 'center' }}>
|
||||||
<ScoreGauge value={score.total} size={200} />
|
<ScoreGauge value={total} size={200} />
|
||||||
</View>
|
</View>
|
||||||
<View style={{ marginTop: 16, padding: 14, backgroundColor: colors.primaryLight, borderRadius: 12 }}>
|
<View style={{ marginTop: 16, padding: 14, backgroundColor: colors.primaryLight, borderRadius: 12 }}>
|
||||||
<Text style={{ color: colors.primaryDark, fontWeight: '700' }}>
|
<Text style={{ color: colors.primaryDark, fontWeight: '700' }}>
|
||||||
💡 상위 15% 수준이에요. 몇 가지 보완하면 90점 이상 가능!
|
{total >= 80
|
||||||
|
? '💪 양호한 수준입니다. 부족한 항목만 채우면 90점+'
|
||||||
|
: total >= 60
|
||||||
|
? '🟡 기본은 있지만 취약 항목이 있어요'
|
||||||
|
: '🔴 현재 보장이 크게 부족합니다. 상담 권장'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Section title="항목별 점수">
|
<Section title="항목별 점수">
|
||||||
{score.breakdown.map((b) => {
|
{breakdown.map((b) => {
|
||||||
const s = statusMap[b.status];
|
const s = statusMap[b.status as keyof typeof statusMap];
|
||||||
return (
|
return (
|
||||||
<Card key={b.label} style={{ marginBottom: 10 }}>
|
<Card key={b.label} style={{ marginBottom: 10 }}>
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||||
@@ -63,16 +89,17 @@ export default function ScoreScreen() {
|
|||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<Section title="⚡ 점수 올리는 법">
|
<Section title="⚡ 점수 올리는 법">
|
||||||
{[
|
{breakdown
|
||||||
{ title: '간병보험 가입', desc: '60대 이후 필수. +15점', route: 'Consult' },
|
.filter((b) => b.status !== 'good')
|
||||||
{ title: '종신보험 보장 강화', desc: '자녀 자산 상속 대비. +10점', route: 'Consult' },
|
.slice(0, 3)
|
||||||
{ title: '치아보험 추가', desc: '중년기 치과 비용 대비. +5점', route: 'Consult' },
|
.map((b) => (
|
||||||
].map((a) => (
|
<Card key={b.label} style={{ marginBottom: 10 }} onPress={() => nav.navigate('Consult')}>
|
||||||
<Card key={a.title} style={{ marginBottom: 10 }} onPress={() => nav.navigate(a.route as any)}>
|
|
||||||
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
|
||||||
<View style={{ flex: 1 }}>
|
<View style={{ flex: 1 }}>
|
||||||
<Text style={typography.bodyBold as any}>{a.title}</Text>
|
<Text style={typography.bodyBold as any}>{b.label} {b.status === 'none' ? '가입' : '보장 강화'}</Text>
|
||||||
<Text style={{ ...typography.caption, color: colors.textSecondary } as any}>{a.desc}</Text>
|
<Text style={{ ...typography.caption, color: colors.textSecondary } as any}>
|
||||||
|
{b.status === 'none' ? '미가입 상태. 필수 점검' : `현재 ${b.value}점 — 상담 통해 보완`}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
|
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
|
||||||
</View>
|
</View>
|
||||||
@@ -80,11 +107,11 @@ export default function ScoreScreen() {
|
|||||||
))}
|
))}
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
<View style={{ paddingHorizontal: 0, paddingTop: 8, gap: 8 }}>
|
<View style={{ paddingTop: 8, gap: 8 }}>
|
||||||
<Button title="맞춤 상담 받기" onPress={() => nav.navigate('Consult')} />
|
<Button title="맞춤 상담 받기" onPress={() => nav.navigate('Consult')} />
|
||||||
<Button title="보험료 다이어트 진단" variant="outline" onPress={() => nav.navigate('PremiumDiet')} />
|
<Button title="보험료 다이어트 진단" variant="outline" onPress={() => nav.navigate('PremiumDiet')} />
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</ScrollView>
|
||||||
</ScreenContainer>
|
</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