feat: 실제 동작하는 백엔드 + DB + 카카오 로그인
Build & Deploy / build-and-deploy (push) Failing after 9s

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:
chpark
2026-04-23 00:32:44 +09:00
parent 035eb0259f
commit f78949c21a
50 changed files with 7006 additions and 826 deletions
+54 -17
View File
@@ -2,14 +2,14 @@ name: Build & Deploy
on:
push:
branches:
- master
- main
branches: [master, main]
workflow_dispatch:
env:
REGISTRY: git.junggomoa.com
IMAGE_NAME: chpark/insurance
WEB_IMAGE: chpark/insurance
API_IMAGE: chpark/insurance-api
API_BASE_URL: https://api.insurance.junggomoa.com
jobs:
build-and-deploy:
@@ -31,16 +31,31 @@ jobs:
username: ${{ secrets.REGISTRY_USER }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Build and push image
- name: Build & push WEB image
uses: docker/build-push-action@v5
with:
context: .
file: ./Dockerfile
build-args: |
EXPO_PUBLIC_API_BASE=${{ env.API_BASE_URL }}
push: true
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.SHORT_SHA }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
${{ env.REGISTRY }}/${{ env.WEB_IMAGE }}:latest
${{ env.REGISTRY }}/${{ env.WEB_IMAGE }}:${{ env.SHORT_SHA }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.WEB_IMAGE }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.WEB_IMAGE }}:buildcache,mode=max
- name: Build & push API image
uses: docker/build-push-action@v5
with:
context: ./server
file: ./server/Dockerfile
push: true
tags: |
${{ env.REGISTRY }}/${{ env.API_IMAGE }}:latest
${{ env.REGISTRY }}/${{ env.API_IMAGE }}:${{ env.SHORT_SHA }}
cache-from: type=registry,ref=${{ env.REGISTRY }}/${{ env.API_IMAGE }}:buildcache
cache-to: type=registry,ref=${{ env.REGISTRY }}/${{ env.API_IMAGE }}:buildcache,mode=max
- name: Set up kubectl
uses: azure/setup-kubectl@v4
@@ -53,16 +68,40 @@ jobs:
echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > $HOME/.kube/config
chmod 600 $HOME/.kube/config
- name: Ensure namespace & registry secret
- name: Ensure namespace, registry & DB secrets
run: |
kubectl apply -f deploy/k8s/namespace.yaml
kubectl -n insurance create secret docker-registry gitea-registry \
--docker-server=${{ env.REGISTRY }} \
--docker-username=${{ secrets.REGISTRY_USER }} \
--docker-password=${{ secrets.REGISTRY_TOKEN }} \
--dry-run=client -o yaml | kubectl apply -f -
- name: Apply manifests
kubectl -n insurance create secret generic postgres-credentials \
--from-literal=username=insurance \
--from-literal=password='${{ secrets.POSTGRES_PASSWORD }}' \
--dry-run=client -o yaml | kubectl apply -f -
kubectl -n insurance create secret generic api-secrets \
--from-literal=jwtSecret='${{ secrets.JWT_SECRET }}' \
--from-literal=databaseUrl="postgresql://insurance:${{ secrets.POSTGRES_PASSWORD }}@postgres:5432/insurance?schema=public" \
--dry-run=client -o yaml | kubectl apply -f -
- name: Deploy Postgres
run: kubectl apply -f deploy/k8s/postgres.yaml
- name: Wait for Postgres
run: kubectl -n insurance rollout status statefulset/postgres --timeout=180s
- name: Deploy API
run: |
kubectl apply -f deploy/k8s/api.yaml
kubectl -n insurance set image deployment/insurance-api \
api=${{ env.REGISTRY }}/${{ env.API_IMAGE }}:${{ env.SHORT_SHA }}
kubectl -n insurance rollout status deployment/insurance-api --timeout=240s
- name: Deploy Web
run: |
kubectl apply -f deploy/k8s/deployment.yaml
kubectl apply -f deploy/k8s/service.yaml
@@ -71,15 +110,13 @@ jobs:
else
kubectl apply -f deploy/k8s/ingress.yaml
fi
- name: Update deployment image & restart
run: |
kubectl -n insurance set image deployment/insurance-web \
web=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ env.SHORT_SHA }}
web=${{ env.REGISTRY }}/${{ env.WEB_IMAGE }}:${{ env.SHORT_SHA }}
kubectl -n insurance rollout status deployment/insurance-web --timeout=180s
- name: Show deployment info
run: |
kubectl -n insurance get deployment,svc,ingress
kubectl -n insurance get deployment,statefulset,svc,ingress,pvc
echo ""
echo "🚀 Deployed: https://insurance.junggomoa.com"
echo "🚀 Web: https://insurance.junggomoa.com"
echo "🔌 API: https://api.insurance.junggomoa.com"
+1
View File
@@ -14,3 +14,4 @@ npm-debug.*
.env
.env.local
*.log
.claude/
+39 -6
View File
@@ -1,16 +1,49 @@
import React from 'react';
import { View, StyleSheet, Platform } from 'react-native';
import { StatusBar } from 'expo-status-bar';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { NavigationContainer } from '@react-navigation/native';
import RootNavigator from './src/navigation/RootNavigator';
import ErrorBoundary from './src/components/ErrorBoundary';
export default function App() {
const isWeb = Platform.OS === 'web';
return (
<SafeAreaProvider>
<NavigationContainer>
<StatusBar style="dark" />
<RootNavigator />
</NavigationContainer>
</SafeAreaProvider>
<ErrorBoundary>
<SafeAreaProvider>
<View style={[styles.outer, isWeb && styles.webOuter]}>
<View style={[styles.inner, isWeb && styles.webInner]}>
<NavigationContainer>
<StatusBar style="dark" />
<RootNavigator />
</NavigationContainer>
</View>
</View>
</SafeAreaProvider>
</ErrorBoundary>
);
}
const styles = StyleSheet.create({
outer: { flex: 1 },
webOuter: {
flex: 1,
backgroundColor: '#E5E7EB',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100%' as any,
},
inner: { flex: 1 },
webInner: {
width: '100%',
maxWidth: 480,
height: '100%' as any,
minHeight: 800,
backgroundColor: '#F9FAFB',
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.15,
shadowRadius: 20,
elevation: 10,
},
});
+79 -131
View File
@@ -1,78 +1,40 @@
# 🚀 배포 가이드
## 자동 배포 파이프라인
# 🚀 배포 가이드 (Full Stack)
```
git push → Gitea Actions → Docker 빌드 → Container Registry → Kubernetes rollout
insurance.junggomoa.com
git push → Gitea Actions
├─ Web Docker build → insurance.junggomoa.com (nginx)
├─ API Docker build → api.insurance.junggomoa.com (Fastify)
└─ Postgres StatefulSet (10Gi PVC)
```
---
## ☑ 한 번만 설정 (Gitea Repo Secrets)
## 📋 한 번만 해야 하는 초기 설정
[https://git.junggomoa.com/chpark/insurance/settings/actions/secrets](https://git.junggomoa.com/chpark/insurance/settings/actions/secrets)
### 1단계 — Gitea 측 설정 (웹 UI)
#### ① Gitea Actions Runner 활성화
1. [https://git.junggomoa.com/chpark/insurance](https://git.junggomoa.com/chpark/insurance) → **Settings**
2. 좌측 **Actions** 메뉴 → **Enable Actions** 체크
3. 조직/인스턴스 관리자가 **Runner**를 하나 등록해둬야 합니다
- Runner 없다면 관리자에게 요청
- 또는 Kubernetes에 `act-runner` Helm 차트로 직접 설치 (아래 스크립트 참고)
#### ② Container Registry 접근 토큰 발급
1. Gitea → 우측 상단 프로필 → **Settings****Applications**
2. **Generate New Token** → 권한 `write:package`, `read:package` 체크
3. 발급된 토큰 복사 (한 번만 보임)
#### ③ Repository Secrets 등록
Repo → **Settings****Secrets and Variables****Actions****Add Secret**:
| Name | Value | 설명 |
| Secret | 값 | 비고 |
|---|---|---|
| `REGISTRY_USER` | `chpark` | Gitea 사용자명 |
| `REGISTRY_TOKEN` | (위에서 발급한 토큰) | Container Registry 인증 |
| `KUBE_CONFIG` | (아래 2단계에서 생성) | base64 인코딩된 kubeconfig |
| `INGRESS_MODE` | `ingress` 또는 `ingressroute` | Traefik 설치 방식 (아래 확인) |
| `REGISTRY_TOKEN` | (Gitea → Settings → Applications → Generate Token, `write:package` 체크) | |
| `KUBE_CONFIG` | 서버에서 생성 base64 kubeconfig | 아래 스크립트 참고 |
| `POSTGRES_PASSWORD` | 임의의 강한 비밀번호 (예: `openssl rand -hex 24`) | DB 비번 |
| `JWT_SECRET` | 임의의 32자 이상 랜덤 문자열 (`openssl rand -hex 32`) | JWT 서명키 |
| `INGRESS_MODE` | `ingress` 또는 `ingressroute` | Traefik 버전 |
---
## 🔑 kubeconfig 생성 (서버에서 한 번만)
### 2단계 — Kubernetes 측 설정 (서버 SSH 1회)
SSH 접속:
```bash
ssh chpark@183.99.177.40
```
#### ① Traefik 설치 방식 확인
```bash
kubectl api-resources | grep -i traefik
```
- 결과에 `ingressroutes.traefik.io` 나오면 → `INGRESS_MODE=ingressroute`
- 아무것도 안 나오면 → `INGRESS_MODE=ingress` (표준 Ingress 사용)
#### ② CI용 kubeconfig 생성 (서비스 어카운트 방식, 권장)
```bash
# 네임스페이스·서비스어카운트 선생성
kubectl create namespace insurance 2>/dev/null || true
kubectl -n insurance create serviceaccount gitea-deployer
kubectl create clusterrolebinding gitea-deployer \
--clusterrole=cluster-admin \
--serviceaccount=insurance:gitea-deployer
# 토큰 생성 (24시간 기본, 필요 시 --duration=8760h 로 1년)
TOKEN=$(kubectl -n insurance create token gitea-deployer --duration=8760h)
# 현재 API 서버 주소
SERVER=$(kubectl config view --minify -o jsonpath='{.clusters[0].cluster.server}')
CA=$(kubectl -n insurance get secret \
$(kubectl -n insurance get sa gitea-deployer -o jsonpath='{.secrets[0].name}' 2>/dev/null || echo "") \
-o jsonpath='{.data.ca\.crt}' 2>/dev/null || \
kubectl config view --minify --raw -o jsonpath='{.clusters[0].cluster.certificate-authority-data}')
CA=$(kubectl config view --minify --raw -o jsonpath='{.clusters[0].cluster.certificate-authority-data}')
# kubeconfig 생성
cat <<EOF > /tmp/gitea-kubeconfig
cat > /tmp/gitea-kubeconfig <<EOF
apiVersion: v1
kind: Config
clusters:
@@ -93,99 +55,85 @@ users:
token: ${TOKEN}
EOF
# base64 인코딩 → Gitea secret에 넣을 값
base64 -w0 /tmp/gitea-kubeconfig
```
출력된 긴 문자열을 복사해서 Gitea의 `KUBE_CONFIG` secret에 붙여넣으세요.
#### ③ DNS 확인
`insurance.junggomoa.com` 이 k8s 클러스터의 Traefik LoadBalancer IP 로 등록돼 있어야 합니다.
출력된 긴 문자열 → `KUBE_CONFIG` 시크릿에 붙여넣기.
## 🌐 DNS
A 레코드 2개 필요:
- `insurance.junggomoa.com` → Traefik LoadBalancer IP
- `api.insurance.junggomoa.com` → Traefik LoadBalancer IP
## 💻 로컬 개발 (docker-compose)
```bash
dig insurance.junggomoa.com +short
# 1. 서버 의존성 + DB 기동
cd server
npm install
cd ..
docker compose up -d postgres
# 2. Prisma 마이그레이션
cd server
cp .env.example .env
npx prisma migrate dev --name init
npm run dev # → http://localhost:4000 (Swagger: /docs)
# 3. 다른 터미널에서 모바일/웹 클라이언트
cd ..
npm run web # → http://localhost:8081
# 브라우저에서 API 가 localhost:4000 에 붙도록 자동 기본값 동작
```
IP가 안 나오거나 잘못됐다면 DNS 관리자에게 추가 요청:
- `insurance.junggomoa.com A <클러스터 LB IP>`
---
## 🧪 API 스모크 테스트
## ✅ 이후부터 — 자동 배포
코드 수정 후:
```bash
git add .
git commit -m "수정 내용"
git push
# 회원가입
curl -X POST http://localhost:4000/auth/register \
-H "Content-Type: application/json" \
-d '{"email":"test@test.com","password":"password123","name":"박철현","age":34,"gender":"MALE","job":"사무직"}'
# 로그인
curl -X POST http://localhost:4000/auth/login \
-H "Content-Type: application/json" \
-d '{"email":"test@test.com","password":"password123"}'
# 카카오 로그인 (카카오 SDK에서 받은 access_token 필요)
curl -X POST http://localhost:4000/auth/kakao \
-H "Content-Type: application/json" \
-d '{"accessToken":"YOUR_KAKAO_ACCESS_TOKEN"}'
```
Gitea Actions 탭에서 진행 상황 확인:
- [https://git.junggomoa.com/chpark/insurance/actions](https://git.junggomoa.com/chpark/insurance/actions)
## 📦 카카오 로그인 연동
**약 3~5분 후** `https://insurance.junggomoa.com` 에 새 버전 반영됩니다.
### 백엔드
- `POST /auth/kakao` 엔드포인트가 `access_token`을 받아 `https://kapi.kakao.com/v2/user/me` 호출 → 프로필 매핑 → JWT 발급
---
### 클라이언트
현재 웹에서는 개발용 `window.prompt`로 토큰 붙여넣기 지원. 실제 운영에서는:
- **Web**: Kakao JS SDK 로 `Kakao.Auth.login()` 호출 → `access_token` 획득 → `/auth/kakao`
- **Native (iOS/Android)**: `@react-native-seoul/kakao-login``bare workflow`로 prebuild 후 연동
## 🧪 수동 1회 배포 (CI 세팅 전 테스트용)
### 필요한 Kakao Developer 설정
1. https://developers.kakao.com/ 앱 생성
2. 플랫폼: Web (도메인 등록: `https://insurance.junggomoa.com`), Android/iOS 별도
3. 카카오 로그인 → Redirect URI: `https://insurance.junggomoa.com/auth/kakao/callback`
4. 동의항목: 닉네임(필수), 프로필 이미지(선택), 이메일(선택)
## 🛠 트러블슈팅
SSH 접속 후:
```bash
# 코드 받기
git clone https://git.junggomoa.com/chpark/insurance.git
cd insurance
# Pod 로그
kubectl -n insurance logs -l app.kubernetes.io/name=insurance-api --tail=100
# 도커 이미지 빌드
docker build -t git.junggomoa.com/chpark/insurance:latest .
# DB 접속
kubectl -n insurance exec -it postgres-0 -- psql -U insurance
# 레지스트리 로그인 & push
docker login git.junggomoa.com -u chpark -p <토큰>
docker push git.junggomoa.com/chpark/insurance:latest
# 마이그레이션 수동 실행
kubectl -n insurance exec -it deploy/insurance-api -- npx prisma migrate deploy
# K8s 시크릿 & 매니페스트 적용
kubectl apply -f deploy/k8s/namespace.yaml
kubectl -n insurance create secret docker-registry gitea-registry \
--docker-server=git.junggomoa.com \
--docker-username=chpark \
--docker-password=<토큰>
kubectl apply -f deploy/k8s/deployment.yaml
kubectl apply -f deploy/k8s/service.yaml
kubectl apply -f deploy/k8s/ingress.yaml # 또는 ingressroute-traefik.yaml
# 상태 확인
kubectl -n insurance get all
kubectl -n insurance get ingress
```
---
## 🔧 트러블슈팅
| 증상 | 원인 | 해결 |
|---|---|---|
| `ImagePullBackOff` | Registry 인증 실패 | `gitea-registry` secret 재생성 |
| `404 page not found` (Traefik) | Ingress host 불일치 | `insurance.junggomoa.com` 철자 확인 |
| TLS 인증서 없음 | cert-manager 미설정 | 관리자에게 와일드카드 인증서 요청 또는 cert-manager 설치 |
| Actions 실패: `no runner` | Runner 미등록 | 조직에 act-runner 등록 필요 |
로그 확인:
```bash
kubectl -n insurance logs -l app.kubernetes.io/name=insurance-web --tail=100
kubectl -n insurance describe deployment insurance-web
kubectl -n insurance describe ingress insurance-web
```
---
## 📁 배포 관련 파일
```
Dockerfile # Multi-stage 빌드 (Expo → nginx)
.dockerignore
.gitea/workflows/deploy.yml # CI/CD 파이프라인
deploy/
├── nginx.conf # SPA fallback + gzip + health
└── k8s/
├── namespace.yaml
├── deployment.yaml # 2 replicas, rolling update
├── service.yaml # ClusterIP :80
├── ingress.yaml # 표준 Ingress (Traefik ingressClass)
└── ingressroute-traefik.yaml # Traefik CRD 버전 (선택)
# 이미지 재배포
kubectl -n insurance rollout restart deployment/insurance-api deployment/insurance-web
```
+3
View File
@@ -1,6 +1,9 @@
FROM node:20-alpine AS builder
WORKDIR /app
ARG EXPO_PUBLIC_API_BASE=https://api.insurance.junggomoa.com
ENV EXPO_PUBLIC_API_BASE=${EXPO_PUBLIC_API_BASE}
COPY package*.json ./
RUN npm ci --legacy-peer-deps --no-audit --no-fund
+5 -2
View File
@@ -9,7 +9,9 @@
"resizeMode": "contain",
"backgroundColor": "#3B82F6"
},
"assetBundlePatterns": ["**/*"],
"assetBundlePatterns": [
"**/*"
],
"ios": {
"supportsTablet": true,
"bundleIdentifier": "com.insurancecare.app",
@@ -38,7 +40,8 @@
"photosPermission": "보험금 청구 서류 첨부를 위해 사진 접근이 필요합니다."
}
],
"expo-notifications"
"expo-notifications",
"expo-font"
]
}
}
+130
View File
@@ -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
+81
View File
@@ -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
+41
View File
@@ -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:
+873 -222
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -17,14 +17,14 @@
"@react-navigation/native-stack": "^6.9.26",
"expo": "~51.0.0",
"expo-device": "~6.0.2",
"expo-font": "^55.0.6",
"expo-image-picker": "~15.0.5",
"expo-font": "~12.0.10",
"expo-image-picker": "~15.1.0",
"expo-linear-gradient": "~13.0.2",
"expo-notifications": "~0.28.0",
"expo-status-bar": "~1.12.1",
"react": "18.2.0",
"react-dom": "^18.2.0",
"react-native": "0.74.3",
"react-native": "0.74.5",
"react-native-chart-kit": "^6.12.0",
"react-native-gesture-handler": "~2.16.1",
"react-native-reanimated": "~3.10.1",
+5
View File
@@ -0,0 +1,5 @@
node_modules
dist
.env*
*.log
.git
+6
View File
@@ -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
+31
View File
@@ -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"]
+2968
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -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"
}
}
+253
View File
@@ -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])
}
+53
View File
@@ -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;
}
+18
View File
@@ -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();
+25
View File
@@ -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: '인증이 필요합니다' });
}
});
});
+19
View File
@@ -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();
});
});
+159
View File
@@ -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,
};
}
+95
View File
@@ -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;
});
}
+35
View File
@@ -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;
});
}
+61
View File
@@ -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);
}
+56
View File
@@ -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) })),
};
}
+22
View File
@@ -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' });
}
+58
View File
@@ -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));
});
}
+112
View File
@@ -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 } });
}
+79
View File
@@ -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 };
}
+36
View File
@@ -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 };
});
}
+22
View File
@@ -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"]
}
+92
View File
@@ -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;
}
}
+128
View File
@@ -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 }),
};
+46
View File
@@ -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' },
});
+27
View File
@@ -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;
}
}
+33 -25
View File
@@ -1,39 +1,49 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import Svg, { Circle } from 'react-native-svg';
import { colors } from '@/theme/colors';
import { typography } from '@/theme/typography';
type Props = {
value: number; // 0-100
value: number;
size?: number;
label?: string;
};
export default function ScoreGauge({ value, size = 180, label = '내 보험 점수' }: Props) {
const stroke = 14;
const radius = (size - stroke) / 2;
const circ = 2 * Math.PI * radius;
const pct = Math.max(0, Math.min(100, value));
const dash = circ * (pct / 100);
const color = pct >= 80 ? colors.success : pct >= 60 ? colors.accent : colors.danger;
const stroke = 14;
return (
<View style={{ width: size, height: size, alignItems: 'center', justifyContent: 'center' }}>
<Svg width={size} height={size}>
<Circle cx={size / 2} cy={size / 2} r={radius} stroke={colors.surfaceAlt} strokeWidth={stroke} fill="none" />
<Circle
cx={size / 2}
cy={size / 2}
r={radius}
stroke={color}
strokeWidth={stroke}
fill="none"
strokeLinecap="round"
strokeDasharray={`${dash}, ${circ}`}
transform={`rotate(-90 ${size / 2} ${size / 2})`}
/>
</Svg>
<View
style={[
styles.track,
{
width: size,
height: size,
borderRadius: size / 2,
borderWidth: stroke,
borderColor: colors.surfaceAlt,
},
]}
/>
<View
style={[
styles.fill,
{
width: size,
height: size,
borderRadius: size / 2,
borderWidth: stroke,
borderTopColor: color,
borderRightColor: pct > 25 ? color : colors.surfaceAlt,
borderBottomColor: pct > 50 ? color : colors.surfaceAlt,
borderLeftColor: pct > 75 ? color : colors.surfaceAlt,
transform: [{ rotate: `${pct * 3.6 - 90}deg` }],
},
]}
/>
<View style={styles.center}>
<Text style={styles.label}>{label}</Text>
<Text style={[styles.value, { color }]}>{pct}</Text>
@@ -44,11 +54,9 @@ export default function ScoreGauge({ value, size = 180, label = '내 보험 점
}
const styles = StyleSheet.create({
center: {
position: 'absolute',
alignItems: 'center',
justifyContent: 'center',
},
track: { position: 'absolute' },
fill: { position: 'absolute' },
center: { position: 'absolute', alignItems: 'center', justifyContent: 'center' },
label: { ...typography.caption, color: colors.textSecondary },
value: { fontSize: 48, fontWeight: '800', marginTop: 2 },
unit: { ...typography.small, color: colors.textTertiary },
+46 -16
View File
@@ -1,6 +1,9 @@
import React from 'react';
import React, { useEffect } from 'react';
import { View, ActivityIndicator } from 'react-native';
import { createNativeStackNavigator } from '@react-navigation/native-stack';
import BottomTabs from './BottomTabs';
import LoginScreen from '@/screens/auth/LoginScreen';
import RegisterScreen from '@/screens/auth/RegisterScreen';
import DiagnosisScreen from '@/screens/DiagnosisScreen';
import AnalysisScreen from '@/screens/AnalysisScreen';
import ScoreScreen from '@/screens/ScoreScreen';
@@ -15,8 +18,12 @@ import AIJudgeScreen from '@/screens/AIJudgeScreen';
import PremiumDietScreen from '@/screens/PremiumDietScreen';
import SilsonGenScreen from '@/screens/SilsonGenScreen';
import NotificationScreen from '@/screens/NotificationScreen';
import { useAuthStore } from '@/store/useAuthStore';
import { colors } from '@/theme/colors';
export type RootStackParamList = {
Login: undefined;
Register: undefined;
Tabs: undefined;
Diagnosis: undefined;
Analysis: undefined;
@@ -37,23 +44,46 @@ export type RootStackParamList = {
const Stack = createNativeStackNavigator<RootStackParamList>();
export default function RootNavigator() {
const { user, loading, hydrate } = useAuthStore();
useEffect(() => {
hydrate();
}, [hydrate]);
if (loading) {
return (
<View style={{ flex: 1, alignItems: 'center', justifyContent: 'center', backgroundColor: colors.background }}>
<ActivityIndicator size="large" color={colors.primary} />
</View>
);
}
return (
<Stack.Navigator screenOptions={{ headerShown: false }}>
<Stack.Screen name="Tabs" component={BottomTabs} />
<Stack.Screen name="Diagnosis" component={DiagnosisScreen} />
<Stack.Screen name="Analysis" component={AnalysisScreen} />
<Stack.Screen name="Score" component={ScoreScreen} />
<Stack.Screen name="Consult" component={ConsultScreen} />
<Stack.Screen name="HiddenMoney" component={HiddenMoneyScreen} />
<Stack.Screen name="DiseaseCode" component={DiseaseCodeScreen} />
<Stack.Screen name="Claim" component={ClaimScreen} />
<Stack.Screen name="HealthCheck" component={HealthCheckScreen} />
<Stack.Screen name="Family" component={FamilyScreen} />
<Stack.Screen name="HospitalChecklist" component={HospitalChecklistScreen} />
<Stack.Screen name="AIJudge" component={AIJudgeScreen} />
<Stack.Screen name="PremiumDiet" component={PremiumDietScreen} />
<Stack.Screen name="SilsonGen" component={SilsonGenScreen} />
<Stack.Screen name="Notifications" component={NotificationScreen} />
{user ? (
<>
<Stack.Screen name="Tabs" component={BottomTabs} />
<Stack.Screen name="Diagnosis" component={DiagnosisScreen} />
<Stack.Screen name="Analysis" component={AnalysisScreen} />
<Stack.Screen name="Score" component={ScoreScreen} />
<Stack.Screen name="Consult" component={ConsultScreen} />
<Stack.Screen name="HiddenMoney" component={HiddenMoneyScreen} />
<Stack.Screen name="DiseaseCode" component={DiseaseCodeScreen} />
<Stack.Screen name="Claim" component={ClaimScreen} />
<Stack.Screen name="HealthCheck" component={HealthCheckScreen} />
<Stack.Screen name="Family" component={FamilyScreen} />
<Stack.Screen name="HospitalChecklist" component={HospitalChecklistScreen} />
<Stack.Screen name="AIJudge" component={AIJudgeScreen} />
<Stack.Screen name="PremiumDiet" component={PremiumDietScreen} />
<Stack.Screen name="SilsonGen" component={SilsonGenScreen} />
<Stack.Screen name="Notifications" component={NotificationScreen} />
</>
) : (
<>
<Stack.Screen name="Login" component={LoginScreen} />
<Stack.Screen name="Register" component={RegisterScreen} />
</>
)}
</Stack.Navigator>
);
}
+7 -24
View File
@@ -1,16 +1,14 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Dimensions } from 'react-native';
import { BarChart } from 'react-native-chart-kit';
import { View, Text, StyleSheet, TouchableOpacity } from 'react-native';
import ScreenContainer from '@/components/ScreenContainer';
import Header from '@/components/Header';
import Card from '@/components/Card';
import Section from '@/components/Section';
import Badge from '@/components/Badge';
import BarChartSimple from '@/components/BarChartSimple';
import { colors } from '@/theme/colors';
import { radius, spacing, typography } from '@/theme/typography';
const screenWidth = Dimensions.get('window').width;
const data = {
'20대': { prem: 18, must: ['실손', '상해'], rec: ['암 (가족력)'], avgCoverage: '2,000만원' },
'30대': { prem: 28, must: ['실손', '암', '종신'], rec: ['여성특화', '치아'], avgCoverage: '5,000만원' },
@@ -79,26 +77,11 @@ export default function AnalysisScreen() {
<Section title="연령별 평균 월 보험료">
<Card padding="md">
<BarChart
data={{
labels: ages as unknown as string[],
datasets: [{ data: ages.map((a) => data[a].prem) }],
}}
width={screenWidth - spacing.lg * 2 - spacing.md * 2}
height={220}
yAxisLabel=""
yAxisSuffix="만"
fromZero
chartConfig={{
backgroundGradientFrom: '#FFF',
backgroundGradientTo: '#FFF',
decimalPlaces: 0,
color: (o = 1) => `rgba(59, 130, 246, ${o})`,
labelColor: (o = 1) => `rgba(107, 114, 128, ${o})`,
propsForBackgroundLines: { stroke: colors.border, strokeDasharray: '3,3' },
barPercentage: 0.6,
}}
style={{ borderRadius: radius.md, marginLeft: -10 }}
<BarChartSimple
data={ages.map((a) => ({ label: a, value: data[a].prem }))}
unit="만"
barColor={colors.primary}
height={200}
/>
</Card>
</Section>
+106 -68
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, RefreshControl, ScrollView } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Ionicons } from '@expo/vector-icons';
@@ -9,86 +9,124 @@ import Card from '@/components/Card';
import Section from '@/components/Section';
import Badge from '@/components/Badge';
import Button from '@/components/Button';
import { useAppStore } from '@/store/useAppStore';
import { useDataStore } from '@/store/useDataStore';
import { colors } from '@/theme/colors';
import { spacing, typography } from '@/theme/typography';
import type { RootStackParamList } from '@/navigation/RootNavigator';
type Nav = NativeStackNavigationProp<RootStackParamList>;
const statusLabel: Record<string, string> = {
SUBMITTED: '접수',
REVIEWING: '심사',
ADDITIONAL_DOCS: '서류보완',
APPROVED: '승인',
PAID: '지급완료',
REJECTED: '거절',
};
export default function ClaimHubScreen() {
const nav = useNavigation<Nav>();
const claims = useAppStore((s) => s.claims);
const { claims, fetchClaims } = useDataStore();
const [refreshing, setRefreshing] = useState(false);
useEffect(() => {
fetchClaims();
}, [fetchClaims]);
const onRefresh = async () => {
setRefreshing(true);
await fetchClaims();
setRefreshing(false);
};
return (
<ScreenContainer>
<ScreenContainer scroll={false}>
<Header title="보험금" showBack={false} />
<View style={{ padding: spacing.lg }}>
<Card>
<Text style={styles.h1}>📸 , </Text>
<Text style={styles.dim}> </Text>
<View style={{ marginTop: 16, gap: 8 }}>
<Button title="보험금 청구하기" onPress={() => nav.navigate('Claim')} />
<Button title="AI 보험금 판정 받기" variant="outline" onPress={() => nav.navigate('AIJudge')} />
</View>
</Card>
</View>
<Section title="진행 상태">
{claims.map((c) => (
<Card key={c.id} style={{ marginBottom: 10 }}>
<View style={{ flexDirection: 'row' }}>
<View style={{ flex: 1 }}>
<Badge
label={c.status}
tone={
c.status === '지급완료' ? 'success' : c.status === '심사' ? 'primary' : c.status === '서류보완' ? 'warning' : 'neutral'
}
/>
<Text style={[typography.bodyBold as any, { marginTop: 6 }]}>{c.title}</Text>
<Text style={styles.dim}>{c.date}</Text>
</View>
{c.amount ? <Text style={styles.amt}>{c.amount.toLocaleString()}</Text> : null}
</View>
</Card>
))}
</Section>
<Section title="보험금 관련 바로가기">
<View style={{ gap: 8 }}>
<Card onPress={() => nav.navigate('HiddenMoney')}>
<View style={styles.row}>
<Ionicons name="cash" size={22} color={colors.success} />
<View style={{ flex: 1, marginLeft: 12 }}>
<Text style={typography.bodyBold as any}> </Text>
<Text style={styles.dim}> </Text>
</View>
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
</View>
</Card>
<Card onPress={() => nav.navigate('DiseaseCode')}>
<View style={styles.row}>
<Ionicons name="search" size={22} color={colors.accent} />
<View style={{ flex: 1, marginLeft: 12 }}>
<Text style={typography.bodyBold as any}> </Text>
<Text style={styles.dim}>"내 질병이 보장되나요?"</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
</View>
</Card>
<Card onPress={() => nav.navigate('HospitalChecklist')}>
<View style={styles.row}>
<Ionicons name="medkit" size={22} color="#14B8A6" />
<View style={{ flex: 1, marginLeft: 12 }}>
<Text style={typography.bodyBold as any}> </Text>
<Text style={styles.dim}> !</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
<ScrollView
contentContainerStyle={{ paddingBottom: 40 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
>
<View style={{ padding: spacing.lg }}>
<Card>
<Text style={styles.h1}>📸 , </Text>
<Text style={styles.dim}> </Text>
<View style={{ marginTop: 16, gap: 8 }}>
<Button title="보험금 청구하기" onPress={() => nav.navigate('Claim')} />
<Button title="AI 보험금 판정 받기" variant="outline" onPress={() => nav.navigate('AIJudge')} />
</View>
</Card>
</View>
</Section>
<Section title="진행 상태">
{claims.length === 0 && (
<Card>
<Text style={styles.dim}> .</Text>
</Card>
)}
{claims.map((c) => {
const tone =
c.status === 'PAID' || c.status === 'APPROVED'
? 'success'
: c.status === 'ADDITIONAL_DOCS' || c.status === 'REJECTED'
? 'warning'
: 'primary';
return (
<Card key={c.id} style={{ marginBottom: 10 }}>
<View style={{ flexDirection: 'row' }}>
<View style={{ flex: 1 }}>
<Badge label={statusLabel[c.status] ?? c.status} tone={tone} />
<Text style={[typography.bodyBold as any, { marginTop: 6 }]}>{c.title}</Text>
<Text style={styles.dim}>
{c.hospital ? `${c.hospital} · ` : ''}
{new Date(c.createdAt).toLocaleDateString()}
</Text>
<Text style={{ ...typography.small, color: colors.textTertiary, marginTop: 2 } as any}>
{c.attachments.length}
</Text>
</View>
{c.amount ? <Text style={styles.amt}>{c.amount.toLocaleString()}</Text> : null}
</View>
</Card>
);
})}
</Section>
<Section title="보험금 관련 바로가기">
<View style={{ gap: 8 }}>
<Card onPress={() => nav.navigate('HiddenMoney')}>
<View style={styles.row}>
<Ionicons name="cash" size={22} color={colors.success} />
<View style={{ flex: 1, marginLeft: 12 }}>
<Text style={typography.bodyBold as any}> </Text>
<Text style={styles.dim}> </Text>
</View>
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
</View>
</Card>
<Card onPress={() => nav.navigate('DiseaseCode')}>
<View style={styles.row}>
<Ionicons name="search" size={22} color={colors.accent} />
<View style={{ flex: 1, marginLeft: 12 }}>
<Text style={typography.bodyBold as any}> </Text>
<Text style={styles.dim}>"내 질병이 보장되나요?"</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
</View>
</Card>
<Card onPress={() => nav.navigate('HospitalChecklist')}>
<View style={styles.row}>
<Ionicons name="medkit" size={22} color="#14B8A6" />
<View style={{ flex: 1, marginLeft: 12 }}>
<Text style={typography.bodyBold as any}> </Text>
<Text style={styles.dim}> !</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
</View>
</Card>
</View>
</Section>
</ScrollView>
</ScreenContainer>
);
}
+64 -115
View File
@@ -1,5 +1,6 @@
import React, { useState } from 'react';
import { View, Text, StyleSheet, TouchableOpacity, Image, Alert } from 'react-native';
import { View, Text, StyleSheet, TouchableOpacity, Image, Alert, TextInput, ScrollView } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import * as ImagePicker from 'expo-image-picker';
import { Ionicons } from '@expo/vector-icons';
import ScreenContainer from '@/components/ScreenContainer';
@@ -8,38 +9,34 @@ import Card from '@/components/Card';
import Section from '@/components/Section';
import Button from '@/components/Button';
import Badge from '@/components/Badge';
import { claimApi } from '@/api/endpoints';
import { useDataStore } from '@/store/useDataStore';
import { colors } from '@/theme/colors';
import { radius, spacing, typography } from '@/theme/typography';
type DocType = 'receipt' | 'diagnosis' | 'detail';
type DocType = 'RECEIPT' | 'DIAGNOSIS' | 'DETAIL';
const docLabels: Record<DocType, { title: string; desc: string; icon: keyof typeof Ionicons.glyphMap }> = {
receipt: { title: '영수증', desc: '진료비 영수증 원본', icon: 'receipt' },
diagnosis: { title: '진단서', desc: '병원 발급 진단서', icon: 'document-text' },
detail: { title: '세부내역서', desc: '비급여 항목 포함', icon: 'list' },
RECEIPT: { title: '영수증', desc: '진료비 영수증 원본', icon: 'receipt' },
DIAGNOSIS: { title: '진단서', desc: '병원 발급 진단서', icon: 'document-text' },
DETAIL: { title: '세부내역서', desc: '비급여 항목 포함', icon: 'list' },
};
const steps = [
{ step: 1, label: '서류 준비', desc: '영수증/진단서 촬영' },
{ step: 2, label: '정보 입력', desc: '병원명·진료일자' },
{ step: 3, label: '자동 전송', desc: '보험사로 즉시 전달' },
{ step: 4, label: '진행 상태', desc: '실시간 확인' },
];
export default function ClaimScreen() {
const nav = useNavigation<any>();
const fetchClaims = useDataStore((s) => s.fetchClaims);
const [docs, setDocs] = useState<Record<DocType, string | null>>({
receipt: null,
diagnosis: null,
detail: null,
RECEIPT: null,
DIAGNOSIS: null,
DETAIL: null,
});
const [title, setTitle] = useState('');
const [hospital, setHospital] = useState('');
const [visitDate, setVisitDate] = useState('');
const [visitDate, setVisitDate] = useState(new Date().toISOString().slice(0, 10));
const [submitting, setSubmitting] = useState(false);
const pick = async (type: DocType) => {
const res = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
quality: 0.7,
});
const res = await ImagePicker.launchImageLibraryAsync({ quality: 0.7 });
if (!res.canceled && res.assets[0]) {
setDocs({ ...docs, [type]: res.assets[0].uri });
}
@@ -47,59 +44,55 @@ export default function ClaimScreen() {
const capture = async (type: DocType) => {
const perm = await ImagePicker.requestCameraPermissionsAsync();
if (!perm.granted) {
Alert.alert('카메라 권한이 필요합니다.');
return;
}
if (!perm.granted) return Alert.alert('카메라 권한이 필요합니다.');
const res = await ImagePicker.launchCameraAsync({ quality: 0.7 });
if (!res.canceled && res.assets[0]) {
setDocs({ ...docs, [type]: res.assets[0].uri });
}
};
const submit = () => {
if (!docs.receipt) {
Alert.alert('영수증은 필수입니다.');
return;
const submit = async () => {
if (!title) return Alert.alert('청구 제목을 입력하세요');
if (!docs.RECEIPT) return Alert.alert('영수증은 필수입니다');
setSubmitting(true);
try {
const claim = await claimApi.create({ title, hospital: hospital || undefined, visitDate });
const uploads: Promise<any>[] = [];
(Object.keys(docs) as DocType[]).forEach((t) => {
const uri = docs[t];
if (uri) uploads.push(claimApi.uploadAttachment(claim.id, uri, t));
});
await Promise.all(uploads);
await fetchClaims();
Alert.alert('청구 접수 완료', '보험사 심사 후 영업일 3~7일 내 지급됩니다.');
nav.goBack();
} catch (e: any) {
Alert.alert('접수 실패', e?.message);
} finally {
setSubmitting(false);
}
Alert.alert('청구 접수 완료', '보험사에서 심사 후 3~7영업일 내 지급됩니다.');
};
return (
<ScreenContainer>
<ScreenContainer scroll={false}>
<Header title="보험금 청구" />
<View style={{ padding: spacing.lg }}>
<ScrollView contentContainerStyle={{ padding: spacing.lg, paddingBottom: 40 }}>
<Card>
<Text style={typography.bodyBold as any}>📸 </Text>
<Text style={typography.bodyBold as any}>📸 </Text>
<View style={{ marginTop: 12, gap: 10 }}>
{steps.map((s) => (
<View key={s.step} style={styles.stepRow}>
<View style={styles.stepCircle}>
<Text style={styles.stepNum}>{s.step}</Text>
</View>
<View style={{ flex: 1 }}>
<Text style={typography.bodyBold as any}>{s.label}</Text>
<Text style={{ ...typography.caption, color: colors.textSecondary } as any}>{s.desc}</Text>
</View>
</View>
))}
</View>
</Card>
<Section title="서류 업로드">
{(Object.keys(docLabels) as DocType[]).map((t) => {
const info = docLabels[t];
const uri = docs[t];
return (
<Card key={t} style={{ marginBottom: 10 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
{(Object.keys(docLabels) as DocType[]).map((t) => {
const info = docLabels[t];
const uri = docs[t];
return (
<View key={t} style={styles.docRow}>
<View style={styles.docIcon}>
<Ionicons name={info.icon} size={22} color={colors.primary} />
</View>
<View style={{ flex: 1, marginLeft: 12 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Text style={typography.bodyBold as any}>{info.title}</Text>
{t === 'receipt' && <Badge label="필수" tone="danger" style={{ marginLeft: 6 }} />}
{t === 'RECEIPT' && <Badge label="필수" tone="danger" style={{ marginLeft: 6 }} />}
</View>
<Text style={{ ...typography.caption, color: colors.textSecondary } as any}>{info.desc}</Text>
</View>
@@ -118,80 +111,36 @@ export default function ClaimScreen() {
</View>
)}
</View>
</Card>
);
})}
</Section>
);
})}
</View>
</Card>
<Section title="청구 정보">
<Card>
<Text style={styles.label}></Text>
<Text style={styles.inputView}>{hospital || '병원명 입력'}</Text>
<View style={{ flexDirection: 'row', gap: 6, flexWrap: 'wrap', marginTop: 4 }}>
{['강남세브란스', '서울아산병원', '삼성의료원', '고려대병원'].map((h) => (
<TouchableOpacity key={h} style={styles.pill} onPress={() => setHospital(h)}>
<Text style={styles.pillText}>{h}</Text>
</TouchableOpacity>
))}
</View>
<Text style={styles.label}> *</Text>
<TextInput style={styles.input} placeholder="예) 발목 염좌 정형외과" value={title} onChangeText={setTitle} placeholderTextColor={colors.textTertiary} />
<Text style={[styles.label, { marginTop: 12 }]}></Text>
<TextInput style={styles.input} placeholder="예) 강남세브란스" value={hospital} onChangeText={setHospital} placeholderTextColor={colors.textTertiary} />
<Text style={[styles.label, { marginTop: 12 }]}></Text>
<Text style={styles.inputView}>{visitDate || '2026-04-22'}</Text>
<TextInput style={styles.input} placeholder="YYYY-MM-DD" value={visitDate} onChangeText={setVisitDate} placeholderTextColor={colors.textTertiary} />
</Card>
</Section>
<View style={{ paddingTop: 16, gap: 8 }}>
<Button title="보험금 청구하기" size="lg" onPress={submit} />
<Button title="AI 판정으로 예상 금액 확인" variant="outline" onPress={() => {}} />
<View style={{ marginTop: 16, gap: 8 }}>
<Button title="보험금 청구하기" size="lg" onPress={submit} loading={submitting} />
<Button title="AI 판정으로 예상 금액 확인" variant="outline" onPress={() => nav.navigate('AIJudge')} />
</View>
</View>
</ScrollView>
</ScreenContainer>
);
}
const styles = StyleSheet.create({
stepRow: { flexDirection: 'row', alignItems: 'center' },
stepCircle: {
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: colors.primary,
alignItems: 'center',
justifyContent: 'center',
marginRight: 12,
},
stepNum: { color: '#FFF', fontWeight: '700' },
docIcon: {
width: 44,
height: 44,
borderRadius: 22,
backgroundColor: colors.primaryLight,
alignItems: 'center',
justifyContent: 'center',
},
docRow: { flexDirection: 'row', alignItems: 'center' },
docIcon: { width: 44, height: 44, borderRadius: 22, backgroundColor: colors.primaryLight, alignItems: 'center', justifyContent: 'center' },
thumb: { width: 50, height: 50, borderRadius: 8 },
smallBtn: {
width: 38,
height: 38,
borderRadius: 19,
borderWidth: 1,
borderColor: colors.primary,
alignItems: 'center',
justifyContent: 'center',
},
smallBtn: { width: 38, height: 38, borderRadius: 19, borderWidth: 1, borderColor: colors.primary, alignItems: 'center', justifyContent: 'center' },
label: { ...typography.caption, color: colors.textSecondary, fontWeight: '600' },
inputView: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: radius.md,
padding: 14,
marginTop: 6,
color: colors.text,
},
pill: {
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: radius.pill,
backgroundColor: colors.surfaceAlt,
},
pillText: { ...typography.small, color: colors.text },
input: { borderWidth: 1, borderColor: colors.border, borderRadius: radius.md, padding: 12, marginTop: 6, color: colors.text, backgroundColor: colors.surface },
});
+187 -91
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, ScrollView, RefreshControl, TextInput, TouchableOpacity, Alert, Modal } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import ScreenContainer from '@/components/ScreenContainer';
import Header from '@/components/Header';
@@ -8,116 +8,204 @@ import Section from '@/components/Section';
import Badge from '@/components/Badge';
import ProgressBar from '@/components/ProgressBar';
import Button from '@/components/Button';
import { useAppStore, FamilyMember } from '@/store/useAppStore';
import { useDataStore } from '@/store/useDataStore';
import { familyApi, type FamilyMember } from '@/api/endpoints';
import { colors } from '@/theme/colors';
import { radius, spacing, typography } from '@/theme/typography';
const relationIcon: Record<FamilyMember['relation'], keyof typeof Ionicons.glyphMap> = {
: 'person',
: 'heart',
: 'happy',
: 'people',
: 'people-outline',
SELF: 'person',
SPOUSE: 'heart',
CHILD: 'happy',
PARENT: 'people',
SIBLING: 'people-outline',
};
const essentialMap: Record<string, string[]> = {
: ['실손', '암', '상해'],
: ['실손', '암', '여성'],
: ['어린이'],
: ['실손', '간병'],
const relationLabel: Record<FamilyMember['relation'], string> = {
SELF: '본인',
SPOUSE: '배우자',
CHILD: '자녀',
PARENT: '부모',
SIBLING: '형제',
};
const essentialMap: Partial<Record<FamilyMember['relation'], string[]>> = {
SELF: ['SILSON', 'CANCER', 'ACCIDENT'],
SPOUSE: ['SILSON', 'CANCER', 'FEMALE'],
CHILD: ['CHILD'],
PARENT: ['SILSON', 'NURSING'],
};
export default function FamilyScreen() {
const family = useAppStore((s) => s.family);
const { family, fetchFamily } = useDataStore();
const [refreshing, setRefreshing] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
useEffect(() => {
fetchFamily();
}, [fetchFamily]);
const analyze = (m: FamilyMember) => {
const required = essentialMap[m.relation] ?? [];
const have = new Set(m.policies.map((p) => p.type));
const missing = required.filter((r) => !have.has(r as any));
const covered = required.length - missing.length;
const score = required.length === 0 ? 100 : Math.round((covered / required.length) * 100);
return { required, missing, covered, score };
const missing = required.filter((r) => !have.has(r));
const score = required.length === 0 ? 100 : Math.round(((required.length - missing.length) / required.length) * 100);
return { required, missing, score };
};
const avgScore = Math.round(family.reduce((a, m) => a + analyze(m).score, 0) / family.length);
const avgScore = family.length > 0 ? Math.round(family.reduce((a, m) => a + analyze(m).score, 0) / family.length) : 0;
return (
<ScreenContainer>
<Header title="가족 보험" />
<View style={{ padding: spacing.lg }}>
<Card>
<Text style={typography.caption as any}>👨👩👧 </Text>
<Text style={{ ...typography.h1 as any, color: colors.primary, marginTop: 6 }}>{avgScore}</Text>
<View style={{ height: 10 }} />
<ProgressBar value={avgScore} />
<Text style={{ ...typography.caption, color: colors.textSecondary, marginTop: 8 } as any}>
{family.length}
</Text>
</Card>
<Section title="가족 구성원">
{family.map((m) => {
const a = analyze(m);
return (
<Card key={m.id} style={{ marginBottom: 10 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={styles.avatar}>
<Ionicons name={relationIcon[m.relation]} size={26} color={colors.primary} />
</View>
<View style={{ flex: 1, marginLeft: 12 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Text style={typography.bodyBold as any}>{m.name}</Text>
<Badge label={m.relation} tone="primary" style={{ marginLeft: 6 }} />
</View>
<Text style={{ ...typography.caption, color: colors.textSecondary } as any}>
{m.age} · {m.gender}
</Text>
</View>
<Text style={{ ...typography.h3 as any, color: scoreColor(a.score) }}>{a.score}</Text>
</View>
<View style={styles.sep} />
<View style={{ gap: 6 }}>
{m.policies.map((p) => (
<View key={p.id} style={styles.policyRow}>
<Ionicons name="checkmark-circle" size={16} color={colors.success} />
<Text style={{ ...typography.body, marginLeft: 6, flex: 1 } as any} numberOfLines={1}>
{p.type} · {p.name}
</Text>
<Text style={{ ...typography.small, color: colors.textSecondary } as any}>
{(p.coverage / 10000).toLocaleString()}
</Text>
</View>
))}
{a.missing.map((miss) => (
<View key={miss} style={styles.policyRow}>
<Ionicons name="close-circle" size={16} color={colors.danger} />
<Text style={{ ...typography.body, marginLeft: 6, color: colors.danger, flex: 1 } as any}>
{miss}
</Text>
</View>
))}
</View>
</Card>
);
})}
</Section>
<Section title="💡 가족 보험 TIP">
<>
<ScreenContainer scroll={false}>
<Header title="가족 보험" />
<ScrollView
contentContainerStyle={{ padding: spacing.lg, paddingBottom: 40 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={async () => { setRefreshing(true); await fetchFamily(); setRefreshing(false); }} />}
>
<Card>
<Text style={typography.body as any}>
{'\n'}
65 / {'\n'}
<Text style={typography.caption as any}>👨👩👧 </Text>
<Text style={{ ...typography.h1 as any, color: colors.primary, marginTop: 6 }}>{avgScore}</Text>
<View style={{ height: 10 }} />
<ProgressBar value={avgScore} />
<Text style={{ ...typography.caption, color: colors.textSecondary, marginTop: 8 } as any}>
{family.length}
</Text>
</Card>
</Section>
<View style={{ paddingTop: 16 }}>
<Button title="가족 구성원 추가" variant="outline" />
</View>
</View>
</ScreenContainer>
<Section
title="가족 구성원"
right={
<TouchableOpacity onPress={() => setModalOpen(true)}>
<Text style={styles.add}>+ </Text>
</TouchableOpacity>
}
>
{family.length === 0 && (
<Card>
<Text style={styles.dim}> .</Text>
</Card>
)}
{family.map((m) => {
const a = analyze(m);
return (
<Card key={m.id} style={{ marginBottom: 10 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={styles.avatar}>
<Ionicons name={relationIcon[m.relation]} size={26} color={colors.primary} />
</View>
<View style={{ flex: 1, marginLeft: 12 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<Text style={typography.bodyBold as any}>{m.name}</Text>
<Badge label={relationLabel[m.relation]} tone="primary" style={{ marginLeft: 6 }} />
</View>
<Text style={{ ...typography.caption, color: colors.textSecondary } as any}>
{m.age} · {m.gender === 'MALE' ? '남' : '여'}
</Text>
</View>
<Text style={{ ...typography.h3 as any, color: scoreColor(a.score) }}>{a.score}</Text>
<TouchableOpacity
style={{ marginLeft: 10 }}
onPress={() => {
Alert.alert('삭제', `${m.name} 구성원을 삭제할까요?`, [
{ text: '취소', style: 'cancel' },
{ text: '삭제', style: 'destructive', onPress: async () => { await familyApi.remove(m.id); fetchFamily(); } },
]);
}}
>
<Ionicons name="trash-outline" size={18} color={colors.textTertiary} />
</TouchableOpacity>
</View>
<View style={styles.sep} />
<View style={{ gap: 6 }}>
{m.policies.length === 0 && (
<Text style={styles.dim}> </Text>
)}
{m.policies.map((p) => (
<View key={p.id} style={styles.policyRow}>
<Ionicons name="checkmark-circle" size={16} color={colors.success} />
<Text style={{ ...typography.body, marginLeft: 6, flex: 1 } as any} numberOfLines={1}>
{p.name}
</Text>
<Text style={{ ...typography.small, color: colors.textSecondary } as any}>
{Math.round(p.coverage / 10000).toLocaleString()}
</Text>
</View>
))}
{a.missing.map((miss) => (
<View key={miss} style={styles.policyRow}>
<Ionicons name="close-circle" size={16} color={colors.danger} />
<Text style={{ ...typography.body, marginLeft: 6, color: colors.danger, flex: 1 } as any}>
{miss}
</Text>
</View>
))}
</View>
</Card>
);
})}
</Section>
</ScrollView>
</ScreenContainer>
<AddFamilyModal open={modalOpen} onClose={() => setModalOpen(false)} onCreated={fetchFamily} />
</>
);
}
function AddFamilyModal({ open, onClose, onCreated }: { open: boolean; onClose: () => void; onCreated: () => void }) {
const [relation, setRelation] = useState<FamilyMember['relation']>('SPOUSE');
const [name, setName] = useState('');
const [age, setAge] = useState('');
const [gender, setGender] = useState<'MALE' | 'FEMALE'>('MALE');
const [submitting, setSubmitting] = useState(false);
const onSubmit = async () => {
if (!name || !age) return Alert.alert('필수 입력');
setSubmitting(true);
try {
await familyApi.create({ relation, name, age: parseInt(age, 10), gender });
onCreated();
onClose();
setName('');
setAge('');
} catch (e: any) {
Alert.alert('등록 실패', e?.message);
} finally {
setSubmitting(false);
}
};
return (
<Modal visible={open} animationType="slide" onRequestClose={onClose}>
<ScreenContainer scroll={false}>
<Header title="가족 추가" showBack={false} right={<TouchableOpacity onPress={onClose}><Ionicons name="close" size={24} color={colors.text} /></TouchableOpacity>} />
<ScrollView contentContainerStyle={{ padding: spacing.lg }}>
<Text style={styles.label}></Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>
{(Object.entries(relationLabel) as Array<[FamilyMember['relation'], string]>).map(([v, l]) => (
<TouchableOpacity key={v} style={[styles.pill, relation === v && styles.pillActive]} onPress={() => setRelation(v)}>
<Text style={[styles.pillText, relation === v && styles.pillTextActive]}>{l}</Text>
</TouchableOpacity>
))}
</View>
<Text style={[styles.label, { marginTop: 12 }]}> *</Text>
<TextInput style={styles.input} value={name} onChangeText={setName} placeholderTextColor={colors.textTertiary} />
<Text style={[styles.label, { marginTop: 12 }]}> *</Text>
<TextInput style={styles.input} value={age} onChangeText={setAge} keyboardType="number-pad" placeholderTextColor={colors.textTertiary} />
<Text style={[styles.label, { marginTop: 12 }]}></Text>
<View style={{ flexDirection: 'row', gap: 6 }}>
{(['MALE', 'FEMALE'] as const).map((g) => (
<TouchableOpacity key={g} style={[styles.pill, gender === g && styles.pillActive]} onPress={() => setGender(g)}>
<Text style={[styles.pillText, gender === g && styles.pillTextActive]}>{g === 'MALE' ? '남성' : '여성'}</Text>
</TouchableOpacity>
))}
</View>
<View style={{ height: 24 }} />
<Button title="등록" size="lg" onPress={onSubmit} loading={submitting} />
</ScrollView>
</ScreenContainer>
</Modal>
);
}
@@ -136,4 +224,12 @@ const styles = StyleSheet.create({
},
sep: { height: 1, backgroundColor: colors.border, marginVertical: 12 },
policyRow: { flexDirection: 'row', alignItems: 'center' },
add: { color: colors.primary, fontWeight: '700' },
dim: { ...typography.caption, color: colors.textSecondary },
label: { ...typography.caption, color: colors.textSecondary, fontWeight: '600', marginBottom: 6 },
input: { borderWidth: 1, borderColor: colors.border, borderRadius: radius.md, padding: 12, color: colors.text, backgroundColor: colors.surface },
pill: { paddingHorizontal: 14, paddingVertical: 8, borderRadius: radius.pill, backgroundColor: colors.surfaceAlt, borderWidth: 1, borderColor: colors.border },
pillActive: { backgroundColor: colors.primary, borderColor: colors.primary },
pillText: { color: colors.text, fontWeight: '500' },
pillTextActive: { color: '#FFF', fontWeight: '700' },
});
+57 -20
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity } from 'react-native';
import React, { useEffect } from 'react';
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, RefreshControl } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
@@ -12,21 +12,42 @@ import ProgressBar from '@/components/ProgressBar';
import Badge from '@/components/Badge';
import { colors } from '@/theme/colors';
import { radius, shadow, spacing, typography } from '@/theme/typography';
import { useAppStore } from '@/store/useAppStore';
import { useAuthStore } from '@/store/useAuthStore';
import { useDataStore } from '@/store/useDataStore';
import type { RootStackParamList } from '@/navigation/RootNavigator';
type Nav = NativeStackNavigationProp<RootStackParamList>;
export default function HomeScreen() {
const nav = useNavigation<Nav>();
const profile = useAppStore((s) => s.profile);
const score = useAppStore((s) => s.score);
const hiddenMoney = useAppStore((s) => s.hiddenMoney);
const notifications = useAppStore((s) => s.notifications);
const user = useAuthStore((s) => s.user);
const { score, notifications, upcoming, fetchAll } = useDataStore();
const [refreshing, setRefreshing] = React.useState(false);
useEffect(() => {
fetchAll();
}, [fetchAll]);
const onRefresh = async () => {
setRefreshing(true);
try {
await fetchAll();
} finally {
setRefreshing(false);
}
};
const totalScore = score?.total ?? user?.profile?.score ?? 0;
const upcomingList = [...(upcoming ?? [])].slice(0, 3);
const recentNotis = [...(notifications ?? [])].slice(0, 3);
return (
<SafeAreaView style={styles.safe} edges={['top']}>
<ScrollView showsVerticalScrollIndicator={false} contentContainerStyle={{ paddingBottom: 32 }}>
<ScrollView
showsVerticalScrollIndicator={false}
contentContainerStyle={{ paddingBottom: 32 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
>
<View style={styles.topBar}>
<View>
<Text style={styles.brand}></Text>
@@ -49,16 +70,16 @@ export default function HomeScreen() {
end={{ x: 1, y: 1 }}
style={styles.hero}
>
<Text style={styles.heroHello}>{profile.name}, 👋</Text>
<Text style={styles.heroHello}>{user?.name ?? '사용자'}, 👋</Text>
<View style={styles.heroRow}>
<View style={{ flex: 1 }}>
<Text style={styles.heroLabel}> </Text>
<Text style={styles.heroScore}>
{score.total}
{totalScore}
<Text style={styles.heroScoreUnit}> /100</Text>
</Text>
<View style={{ height: 10 }} />
<ProgressBar value={score.total} color="#FFF" track="rgba(255,255,255,0.3)" height={6} />
<ProgressBar value={totalScore} color="#FFF" track="rgba(255,255,255,0.3)" height={6} />
</View>
<View style={styles.heroBadge}>
<Ionicons name="shield-checkmark" size={36} color="#FFF" />
@@ -92,7 +113,7 @@ export default function HomeScreen() {
<View style={styles.grid}>
<IconTile icon="stats-chart" label="점수" color="#8B5CF6" bg="#EDE9FE" onPress={() => nav.navigate('Score')} />
<IconTile icon="people" label="연령별 분석" color="#EC4899" bg="#FCE7F3" onPress={() => nav.navigate('Analysis')} />
<IconTile icon="cash" label="숨은보험금" color="#10B981" bg={colors.secondaryLight} badge="47만" onPress={() => nav.navigate('HiddenMoney')} />
<IconTile icon="cash" label="숨은보험금" color="#10B981" bg={colors.secondaryLight} onPress={() => nav.navigate('HiddenMoney')} />
<IconTile icon="search" label="질병코드" color="#F59E0B" bg={colors.accentLight} onPress={() => nav.navigate('DiseaseCode')} />
<IconTile icon="receipt" label="보험금 청구" color={colors.primary} bg={colors.primaryLight} onPress={() => nav.navigate('Claim')} />
<IconTile icon="fitness" label="건강검진 분석" color="#EF4444" bg={colors.dangerLight} onPress={() => nav.navigate('HealthCheck')} />
@@ -110,11 +131,9 @@ export default function HomeScreen() {
<Card onPress={() => nav.navigate('HiddenMoney')}>
<View style={styles.rowBetween}>
<View style={{ flex: 1 }}>
<Badge label="못 받은 보험금 발견" tone="success" />
<Text style={[typography.h2 as any, { marginTop: 8, color: colors.success }]}>
{(hiddenMoney.unclaimed + hiddenMoney.dormant).toLocaleString()}
</Text>
<Text style={styles.dim}> {hiddenMoney.unclaimed.toLocaleString()} + {hiddenMoney.dormant.toLocaleString()}</Text>
<Badge label="못 받은 보험금 조회 가능" tone="success" />
<Text style={[typography.h2 as any, { marginTop: 8, color: colors.success }]}></Text>
<Text style={styles.dim}> API </Text>
</View>
<Ionicons name="chevron-forward" size={22} color={colors.textTertiary} />
</View>
@@ -122,13 +141,31 @@ export default function HomeScreen() {
</Section>
<Section title="🔔 다가오는 알림">
{notifications.map((n) => (
{upcomingList.length === 0 && recentNotis.length === 0 ? (
<Card>
<Text style={styles.dim}> · . .</Text>
</Card>
) : (
upcomingList.map((n: any) => (
<Card key={n.policyId} style={{ marginBottom: 10 }} onPress={() => nav.navigate('Notifications')}>
<View style={styles.rowBetween}>
<View style={{ flex: 1 }}>
<Badge label={`D-${n.days}`} tone={n.days <= 7 ? 'danger' : n.days <= 30 ? 'warning' : 'primary'} />
<Text style={[typography.bodyBold as any, { marginTop: 6 }]}>{n.title}</Text>
<Text style={styles.dim}>{new Date(n.scheduled).toLocaleDateString()}</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
</View>
</Card>
))
)}
{recentNotis.map((n: any) => (
<Card key={n.id} style={{ marginBottom: 10 }} onPress={() => nav.navigate('Notifications')}>
<View style={styles.rowBetween}>
<View style={{ flex: 1 }}>
<Badge
label={n.tone === 'danger' ? '긴급' : n.tone === 'warn' ? '알림' : '안내'}
tone={n.tone === 'danger' ? 'danger' : n.tone === 'warn' ? 'warning' : 'primary'}
label={n.tone === 'DANGER' ? '긴급' : n.tone === 'WARN' ? '알림' : '안내'}
tone={n.tone === 'DANGER' ? 'danger' : n.tone === 'WARN' ? 'warning' : 'primary'}
/>
<Text style={[typography.bodyBold as any, { marginTop: 6 }]}>{n.title}</Text>
<Text style={styles.dim}>{n.body}</Text>
+198 -54
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, RefreshControl, ScrollView, Alert, TextInput, TouchableOpacity, Modal } from 'react-native';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
import { Ionicons } from '@expo/vector-icons';
@@ -9,72 +9,201 @@ import Card from '@/components/Card';
import Section from '@/components/Section';
import Badge from '@/components/Badge';
import Button from '@/components/Button';
import { useAppStore } from '@/store/useAppStore';
import { useDataStore } from '@/store/useDataStore';
import { colors } from '@/theme/colors';
import { spacing, typography } from '@/theme/typography';
import { radius, spacing, typography } from '@/theme/typography';
import type { RootStackParamList } from '@/navigation/RootNavigator';
import { policyApi, type Policy } from '@/api/endpoints';
type Nav = NativeStackNavigationProp<RootStackParamList>;
const POLICY_TYPES: Array<{ v: Policy['type']; label: string }> = [
{ v: 'SILSON', label: '실손' },
{ v: 'CANCER', label: '암' },
{ v: 'LIFE', label: '종신' },
{ v: 'ACCIDENT', label: '상해' },
{ v: 'CHILD', label: '어린이' },
{ v: 'NURSING', label: '간병' },
{ v: 'FEMALE', label: '여성' },
{ v: 'DENTAL', label: '치아' },
{ v: 'DRIVER', label: '운전자' },
{ v: 'CAR', label: '자동차' },
];
export default function MyInsuranceScreen() {
const nav = useNavigation<Nav>();
const family = useAppStore((s) => s.family);
const profile = useAppStore((s) => s.profile);
const me = family.find((m) => m.relation === '본인');
const totalMonthly = me?.policies.reduce((a, p) => a + p.monthlyPremium, 0) ?? 0;
const totalCoverage = me?.policies.reduce((a, p) => a + p.coverage, 0) ?? 0;
const { policies, fetchPolicies, fetchScore } = useDataStore();
const [refreshing, setRefreshing] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
useEffect(() => {
fetchPolicies();
}, [fetchPolicies]);
const own = policies.filter((p) => !p.familyMemberId);
const totalMonthly = own.reduce((a, p) => a + p.monthlyPremium, 0);
const totalCoverage = own.reduce((a, p) => a + p.coverage, 0);
const onRefresh = async () => {
setRefreshing(true);
await fetchPolicies();
setRefreshing(false);
};
const onDelete = (id: string) => {
Alert.alert('보험 삭제', '정말 삭제할까요?', [
{ text: '취소', style: 'cancel' },
{
text: '삭제',
style: 'destructive',
onPress: async () => {
try {
await policyApi.remove(id);
await fetchPolicies();
await fetchScore();
} catch (e: any) {
Alert.alert('삭제 실패', e?.message);
}
},
},
]);
};
return (
<ScreenContainer>
<Header title="내 보험" showBack={false} />
<Card style={{ margin: spacing.lg }}>
<Text style={styles.label}> </Text>
<Text style={styles.big}>{totalMonthly.toLocaleString()}</Text>
<View style={styles.divider} />
<View style={{ flexDirection: 'row' }}>
<View style={{ flex: 1 }}>
<Text style={styles.label}> </Text>
<Text style={styles.mid}>{(totalCoverage / 10000).toLocaleString()}</Text>
</View>
<View style={{ flex: 1 }}>
<Text style={styles.label}> </Text>
<Text style={styles.mid}>{me?.policies.length ?? 0}</Text>
</View>
</View>
</Card>
<Section title="내 보험 목록">
{me?.policies.map((p) => (
<Card key={p.id} style={{ marginBottom: 10 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={styles.icon}>
<Ionicons name="shield-checkmark" size={22} color={colors.primary} />
<>
<ScreenContainer scroll={false}>
<Header title="내 보험" showBack={false} />
<ScrollView
style={{ flex: 1 }}
contentContainerStyle={{ paddingBottom: 40 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
>
<Card style={{ margin: spacing.lg }}>
<Text style={styles.label}> </Text>
<Text style={styles.big}>{totalMonthly.toLocaleString()}</Text>
<View style={styles.divider} />
<View style={{ flexDirection: 'row' }}>
<View style={{ flex: 1 }}>
<Text style={styles.label}> </Text>
<Text style={styles.mid}>{Math.round(totalCoverage / 10000).toLocaleString()}</Text>
</View>
<View style={{ flex: 1, marginLeft: 12 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Badge label={p.type} tone="primary" />
<Text style={styles.insurer}>{p.insurer}</Text>
</View>
<Text style={styles.name}>{p.name}</Text>
<Text style={styles.dim}>
{p.monthlyPremium.toLocaleString()} · {(p.coverage / 10000).toLocaleString()}
</Text>
<View style={{ flex: 1 }}>
<Text style={styles.label}> </Text>
<Text style={styles.mid}>{own.length}</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
</View>
</Card>
))}
</Section>
<Section title="바로가기">
<View style={{ gap: 8 }}>
<Button title="내 보험 점수 상세보기" onPress={() => nav.navigate('Score')} />
<Button title="보험료 다이어트 진단" variant="outline" onPress={() => nav.navigate('PremiumDiet')} />
<Button title="실손 세대 판별" variant="outline" onPress={() => nav.navigate('SilsonGen')} />
<Button title="가족 보험 한눈에 보기" variant="outline" onPress={() => nav.navigate('Family')} />
</View>
</Section>
</ScreenContainer>
<Section title="내 보험 목록" right={<TouchableOpacity onPress={() => setModalOpen(true)}><Text style={styles.addBtn}>+ </Text></TouchableOpacity>}>
{own.length === 0 && (
<Card>
<Text style={styles.dim}> . + .</Text>
</Card>
)}
{own.map((p) => (
<Card key={p.id} style={{ marginBottom: 10 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={styles.icon}>
<Ionicons name="shield-checkmark" size={22} color={colors.primary} />
</View>
<View style={{ flex: 1, marginLeft: 12 }}>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
<Badge label={POLICY_TYPES.find((t) => t.v === p.type)?.label ?? p.type} tone="primary" />
<Text style={styles.insurer}>{p.insurer}</Text>
</View>
<Text style={styles.name}>{p.name}</Text>
<Text style={styles.dim}>
{p.monthlyPremium.toLocaleString()} · {Math.round(p.coverage / 10000).toLocaleString()}
</Text>
</View>
<TouchableOpacity onPress={() => onDelete(p.id)} hitSlop={10}>
<Ionicons name="trash-outline" size={20} color={colors.textTertiary} />
</TouchableOpacity>
</View>
</Card>
))}
</Section>
<Section title="바로가기">
<View style={{ gap: 8 }}>
<Button title="내 보험 점수 상세보기" onPress={() => nav.navigate('Score')} />
<Button title="보험료 다이어트 진단" variant="outline" onPress={() => nav.navigate('PremiumDiet')} />
<Button title="실손 세대 판별" variant="outline" onPress={() => nav.navigate('SilsonGen')} />
<Button title="가족 보험 한눈에 보기" variant="outline" onPress={() => nav.navigate('Family')} />
</View>
</Section>
</ScrollView>
</ScreenContainer>
<AddPolicyModal open={modalOpen} onClose={() => setModalOpen(false)} onCreated={() => { fetchPolicies(); fetchScore(); }} />
</>
);
}
function AddPolicyModal({ open, onClose, onCreated }: { open: boolean; onClose: () => void; onCreated: () => void }) {
const [name, setName] = useState('');
const [insurer, setInsurer] = useState('');
const [type, setType] = useState<Policy['type']>('SILSON');
const [monthlyPremium, setMonthlyPremium] = useState('');
const [coverage, setCoverage] = useState('');
const [joinDate, setJoinDate] = useState(new Date().toISOString().slice(0, 10));
const [submitting, setSubmitting] = useState(false);
const onSubmit = async () => {
if (!name || !insurer || !monthlyPremium || !coverage) {
Alert.alert('필수 항목을 모두 입력하세요');
return;
}
setSubmitting(true);
try {
await policyApi.create({
name,
insurer,
type,
monthlyPremium: parseInt(monthlyPremium, 10),
coverage: parseInt(coverage, 10),
joinDate,
} as any);
onCreated();
onClose();
setName('');
setInsurer('');
setMonthlyPremium('');
setCoverage('');
} catch (e: any) {
Alert.alert('등록 실패', e?.message);
} finally {
setSubmitting(false);
}
};
return (
<Modal visible={open} animationType="slide" onRequestClose={onClose} transparent={false}>
<ScreenContainer scroll={false}>
<Header title="보험 추가" showBack={false} right={<TouchableOpacity onPress={onClose}><Ionicons name="close" size={24} color={colors.text} /></TouchableOpacity>} />
<ScrollView contentContainerStyle={{ padding: spacing.lg, paddingBottom: 40 }}>
<Text style={styles.modalLabel}> </Text>
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6 }}>
{POLICY_TYPES.map((t) => (
<TouchableOpacity key={t.v} style={[styles.pill, type === t.v && styles.pillActive]} onPress={() => setType(t.v)}>
<Text style={[styles.pillText, type === t.v && styles.pillTextActive]}>{t.label}</Text>
</TouchableOpacity>
))}
</View>
<Text style={[styles.modalLabel, { marginTop: 16 }]}> *</Text>
<TextInput style={styles.input} value={name} onChangeText={setName} placeholder="예) 4세대 실손의료비" placeholderTextColor={colors.textTertiary} />
<Text style={[styles.modalLabel, { marginTop: 12 }]}> *</Text>
<TextInput style={styles.input} value={insurer} onChangeText={setInsurer} placeholder="예) 삼성생명" placeholderTextColor={colors.textTertiary} />
<Text style={[styles.modalLabel, { marginTop: 12 }]}> () *</Text>
<TextInput style={styles.input} value={monthlyPremium} onChangeText={setMonthlyPremium} placeholder="예) 32000" keyboardType="number-pad" placeholderTextColor={colors.textTertiary} />
<Text style={[styles.modalLabel, { marginTop: 12 }]}> () *</Text>
<TextInput style={styles.input} value={coverage} onChangeText={setCoverage} placeholder="예) 50000000" keyboardType="number-pad" placeholderTextColor={colors.textTertiary} />
<Text style={[styles.modalLabel, { marginTop: 12 }]}></Text>
<TextInput style={styles.input} value={joinDate} onChangeText={setJoinDate} placeholder="YYYY-MM-DD" placeholderTextColor={colors.textTertiary} />
<View style={{ height: 24 }} />
<Button title="등록하기" size="lg" onPress={onSubmit} loading={submitting} />
</ScrollView>
</ScreenContainer>
</Modal>
);
}
@@ -94,4 +223,19 @@ const styles = StyleSheet.create({
insurer: { ...typography.caption, color: colors.textSecondary },
name: { ...typography.bodyBold, color: colors.text, marginTop: 2 },
dim: { ...typography.caption, color: colors.textSecondary, marginTop: 2 },
addBtn: { color: colors.primary, fontWeight: '700' },
modalLabel: { ...typography.caption, color: colors.textSecondary, fontWeight: '600', marginBottom: 6 },
input: {
borderWidth: 1,
borderColor: colors.border,
borderRadius: radius.md,
padding: 12,
fontSize: 15,
color: colors.text,
backgroundColor: colors.surface,
},
pill: { paddingHorizontal: 12, paddingVertical: 8, borderRadius: radius.pill, backgroundColor: colors.surfaceAlt, borderWidth: 1, borderColor: colors.border },
pillActive: { backgroundColor: colors.primary, borderColor: colors.primary },
pillText: { ...typography.small, color: colors.text },
pillTextActive: { color: '#FFF', fontWeight: '700' },
});
+12 -6
View File
@@ -8,7 +8,8 @@ import Header from '@/components/Header';
import Card from '@/components/Card';
import Section from '@/components/Section';
import Badge from '@/components/Badge';
import { useAppStore } from '@/store/useAppStore';
import { useAuthStore } from '@/store/useAuthStore';
import Button from '@/components/Button';
import { colors } from '@/theme/colors';
import { spacing, typography } from '@/theme/typography';
import type { RootStackParamList } from '@/navigation/RootNavigator';
@@ -28,7 +29,8 @@ const menu = [
export default function MyPageScreen() {
const nav = useNavigation<Nav>();
const profile = useAppStore((s) => s.profile);
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
return (
<ScreenContainer>
@@ -40,12 +42,13 @@ export default function MyPageScreen() {
<Ionicons name="person" size={32} color={colors.primary} />
</View>
<View style={{ flex: 1, marginLeft: 14 }}>
<Text style={typography.h3 as any}>{profile.name}</Text>
<Text style={typography.h3 as any}>{user?.name ?? '사용자'}</Text>
<Text style={styles.dim}>
{profile.age} · {profile.gender} · {profile.job}
{user?.profile?.age ?? '-'} · {user?.profile?.gender === 'FEMALE' ? '여' : '남'} · {user?.profile?.job ?? '-'}
</Text>
<View style={{ marginTop: 6 }}>
<Badge label={`보험점수 ${profile.score}`} tone="primary" />
<View style={{ marginTop: 6, flexDirection: 'row', gap: 6 }}>
<Badge label={`보험점수 ${user?.profile?.score ?? 0}`} tone="primary" />
{user?.provider === 'KAKAO' && <Badge label="카카오" tone="warning" />}
</View>
</View>
</View>
@@ -68,6 +71,9 @@ export default function MyPageScreen() {
))}
</Section>
<View style={{ paddingHorizontal: spacing.lg, marginTop: 16 }}>
<Button title="로그아웃" variant="outline" onPress={logout} />
</View>
<View style={{ alignItems: 'center', marginTop: 24 }}>
<Text style={styles.dim}> v1.0.0</Text>
</View>
+53 -26
View File
@@ -1,5 +1,5 @@
import React from 'react';
import { View, Text, StyleSheet } from 'react-native';
import React, { useEffect, useState } from 'react';
import { View, Text, StyleSheet, RefreshControl, ScrollView } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useNavigation } from '@react-navigation/native';
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
@@ -10,7 +10,8 @@ import Section from '@/components/Section';
import Button from '@/components/Button';
import ScoreGauge from '@/components/ScoreGauge';
import ProgressBar from '@/components/ProgressBar';
import { useAppStore } from '@/store/useAppStore';
import { useDataStore } from '@/store/useDataStore';
import { scoreApi } from '@/api/endpoints';
import { colors } from '@/theme/colors';
import { spacing, typography } from '@/theme/typography';
import type { RootStackParamList } from '@/navigation/RootNavigator';
@@ -26,26 +27,51 @@ const statusMap = {
export default function ScoreScreen() {
const nav = useNavigation<Nav>();
const score = useAppStore((s) => s.score);
const { score, fetchScore } = useDataStore();
const [refreshing, setRefreshing] = useState(false);
useEffect(() => {
fetchScore();
}, [fetchScore]);
const onRefresh = async () => {
setRefreshing(true);
try {
await scoreApi.recompute();
await fetchScore();
} finally {
setRefreshing(false);
}
};
const total = score?.total ?? 0;
const breakdown = score?.breakdown ?? [];
return (
<ScreenContainer>
<ScreenContainer scroll={false}>
<Header title="내 보험 점수" />
<View style={{ padding: spacing.lg }}>
<ScrollView
contentContainerStyle={{ padding: spacing.lg, paddingBottom: 40 }}
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={onRefresh} />}
>
<Card padding="xl">
<View style={{ alignItems: 'center' }}>
<ScoreGauge value={score.total} size={200} />
<ScoreGauge value={total} size={200} />
</View>
<View style={{ marginTop: 16, padding: 14, backgroundColor: colors.primaryLight, borderRadius: 12 }}>
<Text style={{ color: colors.primaryDark, fontWeight: '700' }}>
💡 15% . 90 !
{total >= 80
? '💪 양호한 수준입니다. 부족한 항목만 채우면 90점+'
: total >= 60
? '🟡 기본은 있지만 취약 항목이 있어요'
: '🔴 현재 보장이 크게 부족합니다. 상담 권장'}
</Text>
</View>
</Card>
<Section title="항목별 점수">
{score.breakdown.map((b) => {
const s = statusMap[b.status];
{breakdown.map((b) => {
const s = statusMap[b.status as keyof typeof statusMap];
return (
<Card key={b.label} style={{ marginBottom: 10 }}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
@@ -63,28 +89,29 @@ export default function ScoreScreen() {
</Section>
<Section title="⚡ 점수 올리는 법">
{[
{ title: '간병보험 가입', desc: '60대 이후 필수. +15점', route: 'Consult' },
{ title: '종신보험 보장 강화', desc: '자녀 자산 상속 대비. +10점', route: 'Consult' },
{ title: '치아보험 추가', desc: '중년기 치과 비용 대비. +5점', route: 'Consult' },
].map((a) => (
<Card key={a.title} style={{ marginBottom: 10 }} onPress={() => nav.navigate(a.route as any)}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={{ flex: 1 }}>
<Text style={typography.bodyBold as any}>{a.title}</Text>
<Text style={{ ...typography.caption, color: colors.textSecondary } as any}>{a.desc}</Text>
{breakdown
.filter((b) => b.status !== 'good')
.slice(0, 3)
.map((b) => (
<Card key={b.label} style={{ marginBottom: 10 }} onPress={() => nav.navigate('Consult')}>
<View style={{ flexDirection: 'row', alignItems: 'center' }}>
<View style={{ flex: 1 }}>
<Text style={typography.bodyBold as any}>{b.label} {b.status === 'none' ? '가입' : '보장 강화'}</Text>
<Text style={{ ...typography.caption, color: colors.textSecondary } as any}>
{b.status === 'none' ? '미가입 상태. 필수 점검' : `현재 ${b.value}점 — 상담 통해 보완`}
</Text>
</View>
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
</View>
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
</View>
</Card>
))}
</Card>
))}
</Section>
<View style={{ paddingHorizontal: 0, paddingTop: 8, gap: 8 }}>
<View style={{ paddingTop: 8, gap: 8 }}>
<Button title="맞춤 상담 받기" onPress={() => nav.navigate('Consult')} />
<Button title="보험료 다이어트 진단" variant="outline" onPress={() => nav.navigate('PremiumDiet')} />
</View>
</View>
</ScrollView>
</ScreenContainer>
);
}
+141
View File
@@ -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' },
});
+188
View File
@@ -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' },
});
+76
View File
@@ -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 });
},
}));
+88
View File
@@ -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 } }));
}
},
}));