feat : initial commit

This commit is contained in:
hehe 2025-06-20 07:30:03 +00:00
commit b1f12c5c35
39 changed files with 26364 additions and 0 deletions

78
.dockerignore Normal file
View File

@ -0,0 +1,78 @@
# Dependencies
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
package-lock.json.bak
# Production build
/build
/dist
# Testing
/coverage
*.test.js
*.test.jsx
*.spec.js
*.spec.jsx
**/__tests__/**
**/__mocks__/**
# Environment files
.env
.env.local
.env.development.local
.env.test.local
.env.production.local
# IDE files
.vscode/
.idea/
*.swp
*.swo
*~
# OS files
.DS_Store
.DS_Store?
._*
.Spotlight-V100
.Trashes
ehthumbs.db
Thumbs.db
# Git
.git/
.gitignore
.gitattributes
# Docker
Dockerfile*
docker-compose*
.dockerignore
# Documentation
README.md
README-*
CHANGELOG.md
LICENSE
docs/
*.md
# CI/CD
.github/
.gitlab-ci.yml
.travis.yml
.circleci/
azure-pipelines.yml
# Development tools
.eslintrc*
.prettierrc*
.editorconfig
.nvmrc
# Misc
*.log
.nyc_output
.cache/

26
.gitignore vendored Normal file
View File

@ -0,0 +1,26 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# production
/build
# misc
.DS_Store
.env.local
.env.development.local
.env.test.local
.env.production.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
//* README.md
# HealthSync - AI 건강 코치

64
Dockerfile Normal file
View File

@ -0,0 +1,64 @@
# HealthSync - AI 건강 코치 Dockerfile
# Multi-stage build for optimized React application
# Stage 1: Dependencies installation
FROM node:18-alpine AS deps
WORKDIR /app
# Copy package files for better caching
COPY package*.json ./
# Install ALL dependencies (needed for build)
RUN npm ci && npm cache clean --force
# Stage 2: Build application
FROM node:18-alpine AS builder
WORKDIR /app
# Copy dependencies from previous stage
COPY --from=deps /app/node_modules ./node_modules
# Copy source code
COPY . .
# Set build environment variables
ENV CI=false
ENV NODE_ENV=production
# Build the application
RUN npm run build
# Stage 3: Production runtime
FROM node:18-alpine AS runner
# Install wget for health check (alpine doesn't have curl by default)
RUN apk add --no-cache wget
# Create non-root user for security
RUN addgroup -g 1001 -S healthsync && \
adduser -S healthsync -u 1001 -G healthsync
# Set working directory
WORKDIR /app
# Install serve locally for better security
RUN npm init -y && npm install serve@14.2.1
# Copy built application from builder stage
COPY --from=builder /app/build ./build
# Change ownership to non-root user
RUN chown -R healthsync:healthsync /app
# Expose port 3000
EXPOSE 3000
# Switch to non-root user
USER healthsync
# Health check using wget instead of curl
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1
# Start the application using npx to run local serve
CMD ["npx", "serve", "-s", "build", "-l", "3000", "--no-clipboard"]

1020
Jenkinsfile vendored Normal file

File diff suppressed because it is too large Load Diff

541
README.md Normal file
View File

@ -0,0 +1,541 @@
# 🏥 HealthSync - AI기반 개인형 맞춤 건강관리 서비스
> **"AI와 함께하는 스마트한 건강 습관 만들기"**
직장인을 위한 AI 기반 개인 맞춤형 건강관리 플랫폼으로, 건강검진 데이터 분석부터 일상 건강 습관 형성까지 지원하는 올인원 헬스케어 솔루션입니다.
## 📋 MVP 산출물
### 🎯 1. 발표자료
- [MVP 발표자료](https://gamma.app/docs/HealthSync-mzr82kum8wfpqyf)
### 🏗️ 2. 설계결과
- [논리 아키텍처](https://drive.google.com/file/d/1pmg7BXCfOjf_XytCBd5aiROmLoZv5BQG/view?usp=drive_link)
- [API 설계서](https://docs.google.com/spreadsheets/d/18ApEjdr-ypVo5MlSGuNh8DUjP5tjOd0M/edit?usp=drive_link&ouid=118178534404133188086&rtpof=true&sd=true)
- [시퀀스 다이어그램](https://drive.google.com/file/d/1R5LhWQMk1irxiNmfmTvH1s5Y-OZ5D1oA/view?usp=drive_link)
- [클래스 설계서](https://drive.google.com/file/d/1bIeeTnuoJRsllwnNnvbcG-znOgPD9829/view?usp=drive_link)
- [데이터베이스 설계](https://drive.google.com/file/d/1PIWG69nZMU7_7VsHM1H4A2JiRcPRU0fy/view?usp=drive_link)
- [패키지 구조도](https://drive.google.com/file/d/1uewSxUTtsWmibSnxz4DYHJM1hTd3TlXP/view?usp=drive_link)
- [물리 아키텍처](https://drive.google.com/file/d/1sW-Noid27NFo1Vj1Pqm_qanz7bA21Ef6/view?usp=drive_link)
### 📱 3. Git Repository
- **백엔드 (User/Health/Goal)**: [HealthSync_BE](https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_BE.git)
- **백엔드 (AI Service)**: [HealthSync_Intelligence](https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_Intelligence.git)
- **백엔드 (Motivator Service)**: [HealthSync_Motivator](https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_Motivator.git)
- **Kubernetes Manifest**: [HealthSync_Manifest](https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_Manifest.git)
- **프론트엔드**: [HealthSync_FE](https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_FE.git)
### 🎬 4. 시연 동영상
- [MVP 시연 영상](https://www.youtube.com/watch?v=FW2d6m4Wppo)
---
## 🚀 프로젝트 소개
### 💡 비즈니스 가치
**🏢 회사 관점**
- AI 기반 초개인화 헬스케어 플랫폼 개발로 고객 유치 및 비즈니스 가치 극대화
- 기업 인지도 향상 및 헬스케어 시장 선점
**👤 고객 관점**
- 건강한 생활을 유지하고자 하는 고객에게 지속적인 건강 개선 추적 제공
- 사용자 중심의 개인화된 헬스케어 경험 제공
### 🎯 핵심 기능
#### 🔐 인증 & 온보딩
- **간편 로그인**: 구글 계정 연동으로 쉬운 회원가입
- **개인화 설정**: 직업군별 맞춤 정보 수집 (IT/PM/마케팅/영업/인프라운영/고객상담)
#### 📊 건강 데이터 분석
- **건강검진 연동**: 건강보험공단 데이터 자동 불러오기
- **AI 진단**: Claude API 기반 3줄 요약 건강 상태 분석
- **이력 관리**: 최근 5회 건강검진 결과 시각화
#### 🎯 스마트 미션 시스템
- **AI 추천**: 건강 상태 + 직업 특성 고려한 5개 맞춤 미션 제안
- **습관 추적**: 일일 목표 설정 및 달성률 모니터링
- **성취 관리**: 연속 달성 기록 및 마일스톤 보상
#### 💬 AI 헬스 코치
- **실시간 상담**: 건강 관련 질문에 대한 전문적 답변
- **독려 메시지**: 주기적 동기부여 및 리마인더 알림
- **축하 시스템**: 미션 달성 시 즉각적인 피드백 제공
### 🏗️ 기술 아키텍처
#### 📱 Frontend
- **React 18** + **TypeScript**
- **Material-UI** 컴포넌트 라이브러리
- 모바일 퍼스트 반응형 디자인
- PWA 지원으로 네이티브 앱 수준 경험
#### ⚙️ Backend (마이크로서비스)
- **Spring Boot 3.4.0** + **Java 21**
- **Clean/Hexagonal Architecture** 적용
- **Spring Cloud Gateway** 통합 API 게이트웨이
- **JWT 기반** 인증/인가 시스템
- **User/Health/Goal**: 핵심 비즈니스 로직 처리
- **Motivator**: 동기부여 및 알림 전담 서비스
#### 🧠 AI Service
- **Python FastAPI** + **Claude API** 직접 연동
- 비동기 처리로 빠른 응답 시간 보장
- Redis 캐싱으로 성능 최적화
#### 🗄️ Data Layer
- **PostgreSQL 15**: 메인 데이터베이스
- **Redis 7**: 캐싱 및 세션 관리
- **Azure Blob Storage**: 건강검진 파일 저장
#### ☁️ Infrastructure
- **Azure Kubernetes Service (AKS)**: 컨테이너 오케스트레이션
- **Azure Container Registry (ACR)**: 컨테이너 이미지 저장소
- **Jenkins + ArgoCD**: CI/CD 파이프라인
- **GitOps**: Manifest 저장소 기반 배포 자동화
- **Nginx Ingress**: 로드밸런싱 및 SSL 종료
---
## 🛠️ 로컬 실행 가이드
### 📋 사전 요구사항
- **Java 21+**
- **Node.js 18+**
- **Docker & Docker Compose**
- **Git**
### 🔧 환경 설정
#### 1. 저장소 클론
```bash
# 백엔드 (User/Health/Goal 서비스)
git clone https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_BE.git
cd HealthSync_BE
# 백엔드 (AI 서비스)
git clone https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_Intelligence.git
cd HealthSync_Intelligence
# 백엔드 (Motivator 서비스)
git clone https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_Motivator.git
cd HealthSync_Motivator
# Kubernetes Manifest
git clone https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_Manifest.git
cd HealthSync_Manifest
# 프론트엔드
git clone https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_FE.git
cd HealthSync_FE
```
#### 2. 환경변수 설정
```bash
# .env 파일 생성
cp .env.example .env
# 필수 환경변수 설정
export CLAUDE_API_KEY=your_claude_api_key
export GOOGLE_CLIENT_ID=your_google_client_id
export GOOGLE_CLIENT_SECRET=your_google_client_secret
export DB_PASSWORD=your_database_password
```
### 🚀 실행 방법
#### Option 1: Docker Compose (추천)
```bash
# 전체 서비스 시작
docker-compose up -d
# 특정 서비스만 시작
docker-compose up -d postgres redis
docker-compose up -d backend
docker-compose up -d frontend
# 로그 확인
docker-compose logs -f
```
#### Option 2: 개별 실행
```bash
# 1. 데이터베이스 시작
docker-compose up -d postgres redis
# 2. 백엔드 서비스 시작
# User/Health/Goal 서비스
cd HealthSync_BE
./gradlew bootRun
# AI 서비스 (별도 터미널)
cd HealthSync_Intelligence
python -m uvicorn main:app --reload --port 8083
# Motivator 서비스 (별도 터미널)
cd HealthSync_Motivator
./gradlew bootRun # 또는 python main.py (기술스택에 따라)
# 3. 프론트엔드 시작
cd HealthSync_FE
npm install
npm start
```
### 🌐 접속 정보
- **프론트엔드**: http://localhost:3000
- **API Gateway**: http://localhost:8080
- **Swagger UI**: http://localhost:8080/swagger-ui.html
- **PostgreSQL**: localhost:5432
- **Redis**: localhost:6379
---
## 🚢 CI/CD 가이드
### 🔄 자동 배포 파이프라인
#### 1. CI 파이프라인 (Jenkins)
```yaml
stages:
- checkout: 소스 코드 체크아웃
- test: 단위 테스트 및 통합 테스트 실행
- build: Docker 이미지 빌드
- push: Azure Container Registry에 이미지 푸시
- deploy: Manifest 저장소 업데이트 및 ArgoCD 배포 트리거
```
#### 2. CD 파이프라인 (ArgoCD)
```yaml
source:
repoURL: https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_Manifest.git
path: overlays/production
destination:
server: https://kubernetes.default.svc
namespace: healthsync-prod
```
### 🏗️ 배포 환경
#### Development
- **URL**: https://dev.healthsync.com
- **자동 배포**: main 브랜치 push 시
- **데이터베이스**: Development PostgreSQL
#### Production
- **URL**: https://healthsync.com
- **배포 방식**: 수동 승인 후 배포
- **데이터베이스**: Production PostgreSQL (백업 설정)
### 📊 모니터링
- **Prometheus**: 메트릭 수집
- **Grafana**: 대시보드 및 알림
- **Application Insights**: 애플리케이션 모니터링
---
## 👥 팀 구성 (Agentic Workflow)
### 🎯 M사상 실천
**Value-Oriented** | **Interactive** | **Iterative**
| 역할 | 이름 | 담당 영역 |
|------|------|-----------|
| **PO** | 김PO "PO" | 제품 기획 및 비즈니스 가치 정의 |
| **UI/UX** | 김소영 "유엑스" | 사용자 경험 설계 및 인터페이스 디자인 |
| **Frontend** | 이준수 "프론트" | React 기반 웹 애플리케이션 개발 |
| **Backend** | 박서준 "백엔드" | Spring Boot 마이크로서비스 개발 |
| **DevOps** | 정민아 "데브옵스" | CI/CD 및 인프라 자동화 |
| **Data Science** | 최태호 "데사이" | AI 모델 개발 및 데이터 분석 |
| **Medical** | 김지현 "닥터킴" | 의료 전문 자문 및 검증 |
| **QA** | 윤도현 "테스터" | 품질 보증 및 테스트 자동화 |
| **Security** | 서예린 "시큐어" | 보안 및 개인정보보호 |
---
## 📜 라이선스
MIT License - 자세한 내용은 [LICENSE](./LICENSE) 파일을 참조하세요.
---
## 📞 문의 및 지원
- **이슈 제출**: [Gitea Issues](https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_FE/issues)
- **기능 요청**: [Feature Request](https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_FE/discussions)
- **보안 문의**: security@healthsync.com
---
*🎯 건강한 습관, AI와 함께 시작하세요! HealthSync는 여러분의 건강한 내일을 응원합니다.*# 🏥 HealthSync - AI기반 개인형 맞춤 건강관리 서비스
> **"AI와 함께하는 스마트한 건강 습관 만들기"**
직장인을 위한 AI 기반 개인 맞춤형 건강관리 플랫폼으로, 건강검진 데이터 분석부터 일상 건강 습관 형성까지 지원하는 올인원 헬스케어 솔루션입니다.
## 📋 MVP 산출물
### 🎯 1. 발표자료
- [MVP 발표자료](https://gamma.app/docs/HealthSync-mzr82kum8wfpqyf)
### 🏗️ 2. 설계결과
- [논리 아키텍처](https://drive.google.com/file/d/1pmg7BXCfOjf_XytCBd5aiROmLoZv5BQG/view?usp=drive_link)
- [API 설계서](https://docs.google.com/spreadsheets/d/18ApEjdr-ypVo5MlSGuNh8DUjP5tjOd0M/edit?usp=drive_link&ouid=118178534404133188086&rtpof=true&sd=true)
- [시퀀스 다이어그램](https://drive.google.com/file/d/1R5LhWQMk1irxiNmfmTvH1s5Y-OZ5D1oA/view?usp=drive_link)
- [클래스 설계서](https://drive.google.com/file/d/1bIeeTnuoJRsllwnNnvbcG-znOgPD9829/view?usp=drive_link)
- [데이터베이스 설계](https://drive.google.com/file/d/1PIWG69nZMU7_7VsHM1H4A2JiRcPRU0fy/view?usp=drive_link)
- [패키지 구조도](https://drive.google.com/file/d/1uewSxUTtsWmibSnxz4DYHJM1hTd3TlXP/view?usp=drive_link)
- [물리 아키텍처](https://drive.google.com/file/d/1sW-Noid27NFo1Vj1Pqm_qanz7bA21Ef6/view?usp=drive_link)
### 📱 3. Git Repository
- **백엔드 (User/Health/Goal)**: [HealthSync_BE](https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_BE.git)
- **백엔드 (AI Service)**: [HealthSync_Intelligence](https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_Intelligence.git)
- **백엔드 (Motivator Service)**: [HealthSync_Motivator](https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_Motivator.git)
- **Kubernetes Manifest**: [HealthSync_Manifest](https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_Manifest.git)
- **프론트엔드**: [HealthSync_FE](https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_FE.git)
### 🎬 4. 시연 동영상
- [MVP 시연 영상](https://youtube.com/shorts/ptJ4hGYEh4o?feature=share)
---
## 🚀 프로젝트 소개
### 💡 비즈니스 가치
**🏢 회사 관점**
- AI 기반 초개인화 헬스케어 플랫폼 개발로 고객 유치 및 비즈니스 가치 극대화
- 기업 인지도 향상 및 헬스케어 시장 선점
**👤 고객 관점**
- 건강한 생활을 유지하고자 하는 고객에게 지속적인 건강 개선 추적 제공
- 사용자 중심의 개인화된 헬스케어 경험 제공
### 🎯 핵심 기능
#### 🔐 인증 & 온보딩
- **간편 로그인**: 구글 계정 연동으로 쉬운 회원가입
- **개인화 설정**: 직업군별 맞춤 정보 수집 (IT/PM/마케팅/영업/인프라운영/고객상담)
#### 📊 건강 데이터 분석
- **건강검진 연동**: 건강보험공단 데이터 자동 불러오기
- **AI 진단**: Claude API 기반 3줄 요약 건강 상태 분석
- **이력 관리**: 최근 5회 건강검진 결과 시각화
#### 🎯 스마트 미션 시스템
- **AI 추천**: 건강 상태 + 직업 특성 고려한 5개 맞춤 미션 제안
- **습관 추적**: 일일 목표 설정 및 달성률 모니터링
- **성취 관리**: 연속 달성 기록 및 마일스톤 보상
#### 💬 AI 헬스 코치
- **실시간 상담**: 건강 관련 질문에 대한 전문적 답변
- **독려 메시지**: 주기적 동기부여 및 리마인더 알림
- **축하 시스템**: 미션 달성 시 즉각적인 피드백 제공
### 🏗️ 기술 아키텍처
#### 📱 Frontend
- **React 18** + **TypeScript**
- **Material-UI** 컴포넌트 라이브러리
- 모바일 퍼스트 반응형 디자인
- PWA 지원으로 네이티브 앱 수준 경험
#### ⚙️ Backend (마이크로서비스)
- **Spring Boot 3.4.0** + **Java 21**
- **Clean/Hexagonal Architecture** 적용
- **Spring Cloud Gateway** 통합 API 게이트웨이
- **JWT 기반** 인증/인가 시스템
- **User/Health/Goal**: 핵심 비즈니스 로직 처리
- **Motivator**: 동기부여 및 알림 전담 서비스
#### 🧠 AI Service
- **Python FastAPI** + **Claude API** 직접 연동
- 비동기 처리로 빠른 응답 시간 보장
- Redis 캐싱으로 성능 최적화
#### 🗄️ Data Layer
- **PostgreSQL 15**: 메인 데이터베이스
- **Redis 7**: 캐싱 및 세션 관리
- **Azure Blob Storage**: 건강검진 파일 저장
#### ☁️ Infrastructure
- **Azure Kubernetes Service (AKS)**: 컨테이너 오케스트레이션
- **Azure Container Registry (ACR)**: 컨테이너 이미지 저장소
- **Jenkins + ArgoCD**: CI/CD 파이프라인
- **GitOps**: Manifest 저장소 기반 배포 자동화
- **Nginx Ingress**: 로드밸런싱 및 SSL 종료
---
## 🛠️ 로컬 실행 가이드
### 📋 사전 요구사항
- **Java 21+**
- **Node.js 18+**
- **Docker & Docker Compose**
- **Git**
### 🔧 환경 설정
#### 1. 저장소 클론
```bash
# 백엔드 (User/Health/Goal 서비스)
git clone https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_BE.git
cd HealthSync_BE
# 백엔드 (AI 서비스)
git clone https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_Intelligence.git
cd HealthSync_Intelligence
# 백엔드 (Motivator 서비스)
git clone https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_Motivator.git
cd HealthSync_Motivator
# Kubernetes Manifest
git clone https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_Manifest.git
cd HealthSync_Manifest
# 프론트엔드
git clone https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_FE.git
cd HealthSync_FE
```
#### 2. 환경변수 설정
```bash
# .env 파일 생성
cp .env.example .env
# 필수 환경변수 설정
export CLAUDE_API_KEY=your_claude_api_key
export GOOGLE_CLIENT_ID=your_google_client_id
export GOOGLE_CLIENT_SECRET=your_google_client_secret
export DB_PASSWORD=your_database_password
```
### 🚀 실행 방법
#### Option 1: Docker Compose (추천)
```bash
# 전체 서비스 시작
docker-compose up -d
# 특정 서비스만 시작
docker-compose up -d postgres redis
docker-compose up -d backend
docker-compose up -d frontend
# 로그 확인
docker-compose logs -f
```
#### Option 2: 개별 실행
```bash
# 1. 데이터베이스 시작
docker-compose up -d postgres redis
# 2. 백엔드 서비스 시작
# User/Health/Goal 서비스
cd HealthSync_BE
./gradlew bootRun
# AI 서비스 (별도 터미널)
cd HealthSync_Intelligence
python -m uvicorn main:app --reload --port 8083
# Motivator 서비스 (별도 터미널)
cd HealthSync_Motivator
./gradlew bootRun # 또는 python main.py (기술스택에 따라)
# 3. 프론트엔드 시작
cd HealthSync_FE
npm install
npm start
```
### 🌐 접속 정보
- **프론트엔드**: http://localhost:3000
- **API Gateway**: http://localhost:8080
- **Swagger UI**: http://localhost:8080/swagger-ui.html
- **PostgreSQL**: localhost:5432
- **Redis**: localhost:6379
---
## 🚢 CI/CD 가이드
### 🔄 자동 배포 파이프라인
#### 1. CI 파이프라인 (Jenkins)
```yaml
stages:
- checkout: 소스 코드 체크아웃
- test: 단위 테스트 및 통합 테스트 실행
- build: Docker 이미지 빌드
- push: Azure Container Registry에 이미지 푸시
- deploy: Manifest 저장소 업데이트 및 ArgoCD 배포 트리거
```
#### 2. CD 파이프라인 (ArgoCD)
```yaml
source:
repoURL: https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_Manifest.git
path: overlays/production
destination:
server: https://kubernetes.default.svc
namespace: healthsync-prod
```
### 🏗️ 배포 환경
#### Development
- **URL**: https://dev.healthsync.com
- **자동 배포**: main 브랜치 push 시
- **데이터베이스**: Development PostgreSQL
#### Production
- **URL**: https://healthsync.com
- **배포 방식**: 수동 승인 후 배포
- **데이터베이스**: Production PostgreSQL (백업 설정)
### 📊 모니터링
- **Prometheus**: 메트릭 수집
- **Grafana**: 대시보드 및 알림
- **Application Insights**: 애플리케이션 모니터링
---
## 👥 팀 구성 (Agentic Workflow)
### 🎯 M사상 실천
**Value-Oriented** | **Interactive** | **Iterative**
| 역할 | 이름 | 담당 영역 |
|------|------|-----------|
| **PO** | 김PO "PO" | 제품 기획 및 비즈니스 가치 정의 |
| **UI/UX** | 김소영 "유엑스" | 사용자 경험 설계 및 인터페이스 디자인 |
| **Frontend** | 이준수 "프론트" | React 기반 웹 애플리케이션 개발 |
| **Backend** | 박서준 "백엔드" | Spring Boot 마이크로서비스 개발 |
| **DevOps** | 정민아 "데브옵스" | CI/CD 및 인프라 자동화 |
| **Data Science** | 최태호 "데사이" | AI 모델 개발 및 데이터 분석 |
| **Medical** | 김지현 "닥터킴" | 의료 전문 자문 및 검증 |
| **QA** | 윤도현 "테스터" | 품질 보증 및 테스트 자동화 |
| **Security** | 서예린 "시큐어" | 보안 및 개인정보보호 |
---
## 📜 라이선스
MIT License - 자세한 내용은 [LICENSE](./LICENSE) 파일을 참조하세요.
---
## 📞 문의 및 지원
- **이슈 제출**: [Gitea Issues](https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_FE/issues)
- **기능 요청**: [Feature Request](https://gitea.cbiz.kubepia.net/dg04-1tier/HealthSync_FE/discussions)
- **보안 문의**: security@healthsync.com
---
*🎯 건강한 습관, AI와 함께 시작하세요! HealthSync는 여러분의 건강한 내일을 응원합니다.*

View File

@ -0,0 +1,32 @@
# Node.js Multi-stage build for React
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .
RUN npm run build
# Production stage with simple Nginx
FROM nginx:alpine
# Copy built app
COPY --from=builder /app/build /usr/share/nginx/html
# Create simple nginx config for React SPA
RUN echo 'server {' > /etc/nginx/conf.d/default.conf && \
echo ' listen 80;' >> /etc/nginx/conf.d/default.conf && \
echo ' server_name localhost;' >> /etc/nginx/conf.d/default.conf && \
echo ' root /usr/share/nginx/html;' >> /etc/nginx/conf.d/default.conf && \
echo ' index index.html;' >> /etc/nginx/conf.d/default.conf && \
echo ' location / {' >> /etc/nginx/conf.d/default.conf && \
echo ' try_files $uri $uri/ /index.html;' >> /etc/nginx/conf.d/default.conf && \
echo ' }' >> /etc/nginx/conf.d/default.conf && \
echo ' location /health {' >> /etc/nginx/conf.d/default.conf && \
echo ' return 200 "healthy";' >> /etc/nginx/conf.d/default.conf && \
echo ' add_header Content-Type text/plain;' >> /etc/nginx/conf.d/default.conf && \
echo ' }' >> /etc/nginx/conf.d/default.conf && \
echo '}' >> /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@ -0,0 +1,50 @@
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log notice;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log /var/log/nginx/access.log main;
sendfile on;
keepalive_timeout 65;
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/javascript application/xml+rss application/json;
location / {
try_files $uri $uri/ /index.html;
}
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
add_header X-Frame-Options DENY;
add_header X-Content-Type-Options nosniff;
add_header X-XSS-Protection "1; mode=block";
}
}

33
eslint.config.js Normal file
View File

@ -0,0 +1,33 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
export default [
{ ignores: ['dist'] },
{
files: ['**/*.{js,jsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
ecmaVersion: 'latest',
ecmaFeatures: { jsx: true },
sourceType: 'module',
},
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...js.configs.recommended.rules,
...reactHooks.configs.recommended.rules,
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
]

36
index.html Normal file
View File

@ -0,0 +1,36 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#667eea" />
<meta name="description" content="AI 기반 개인 맞춤형 건강관리 서비스" />
<title>HealthSync - AI 건강 코치</title>
<style>
body {
margin: 0;
padding: 0;
background: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="runtime-env.js"></script>
</body>
</html>
//* public/runtime-env.js
window.__runtime_config__ = {
AUTH_URL: 'http://20.1.2.3/auth',
HEALTH_URL: 'http://20.1.2.3/health',
GOAL_URL: 'http://20.1.2.3/goal',
CHAT_URL: 'http://20.1.2.3/chat'
}

19663
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

42
package.json Normal file
View File

@ -0,0 +1,42 @@
{
"name": "healthsync-mobile",
"version": "0.1.0",
"private": true,
"proxy": "http://team1tier.20.214.196.128.nip.io/api",
"dependencies": {
"@react-oauth/google": "^0.12.2",
"@testing-library/jest-dom": "^5.16.4",
"@testing-library/react": "^13.3.0",
"@testing-library/user-event": "^13.5.0",
"jwt-decode": "^4.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.3.0",
"react-scripts": "5.0.1",
"web-vitals": "^2.1.4"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

BIN
public.zip Normal file

Binary file not shown.

31
public/index.html Normal file
View File

@ -0,0 +1,31 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta name="description" content="AI 기반 개인 맞춤형 건강관리 서비스" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<title>HealthSync - AI 건강 코치</title>
<!-- Runtime configuration을 먼저 로드 -->
<script>
// 기본값 설정 (runtime-env.js가 로드되지 않을 경우 대비)
window.__runtime_config__ = {
GOOGLE_CLIENT_ID: '487051701969-djfjpee90l9hesopa2dgqll4sagbho0p.apps.googleusercontent.com',
AUTH_URL: 'http://team1tier.20.214.196.128.nip.io/api/auth',
HEALTH_URL: 'http://team1tier.20.214.196.128.nip.io/api/health',
INTELLIGENCE_URL: 'http://team1tier.20.214.196.128.nip.io/api/intelligence',
GOAL_URL: 'http://team1tier.20.214.196.128.nip.io/api/goals',
MOTIVATOR_URL: 'http://team1tier.20.214.196.128.nip.io/api/motivator'
};
</script>
<script src="%PUBLIC_URL%/runtime-env.js" onerror="console.log('runtime-env.js 로드 실패, 기본값 사용')"></script>
</head>
<body>
<noscript>JavaScript를 활성화해야 이 앱을 실행할 수 있습니다.</noscript>
<div id="root"></div>
</body>
</html>

28
public/index.html.backup Normal file
View File

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#667eea" />
<meta name="description" content="AI 기반 개인 맞춤형 건강관리 서비스" />
<title>HealthSync - AI 건강 코치</title>
<style>
body {
margin: 0;
padding: 0;
background: #f8f9fa;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
</style>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<script src="runtime-env.js"></script>
</body>
</html>

8
public/runtime-env.js Normal file
View File

@ -0,0 +1,8 @@
window.__runtime_config__ = {
AUTH_URL: 'http://localhost:8081', // 인증 서비스 (/api/auth/login)
USER_URL: 'http://localhost:8081/api/user', // 사용자 서비스 (/api/users/register, /api/users/profile)
HEALTH_URL: 'http://localhost:8082/api/health', // 건강 서비스 (/api/health/checkup/sync, /api/health/checkup/history)
GOAL_URL: 'http://localhost:8084/api/goals', // 목표 서비스 (/api/goals/missions/active, /api/goals/missions/select)
INTELLIGENCE_URL: 'http://team1tier.20.214.196.128.nip.io/api/intelligence', // AI 서비스 (/api/intelligence/health/diagnosis, /api/intelligence/missions/recommend)
CHAT_URL: 'http://team1tier.20.214.196.128.nip.io/api/chat' // 채팅 서비스 (/api/chat/consultation)
}

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

818
src/App.css Normal file
View File

@ -0,0 +1,818 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f8f9fa;
color: #333;
min-height: 100vh;
overflow-x: hidden;
}
.App {
width: 100%;
min-height: 100vh;
display: flex;
justify-content: center;
align-items: flex-start;
}
/* 모바일 우선 - 전체 화면 사용 */
.phone-container {
width: 100vw;
min-height: 100vh;
background: #fff;
position: relative;
overflow: hidden;
box-shadow: none;
}
/* 태블릿 세로 모드 - 전체 화면 꽉 채우기 */
@media (min-width: 768px) and (max-width: 1023px) and (orientation: portrait) {
body {
padding: 0;
display: block;
}
.phone-container {
width: 100vw;
height: 100vh;
border-radius: 0;
box-shadow: none;
margin: 0;
}
}
/* 태블릿 가로 모드 - 전체 화면 꽉 채우기! */
@media (min-width: 768px) and (orientation: landscape) {
body {
padding: 0;
display: block;
}
.phone-container {
width: 100vw;
height: 100vh;
border-radius: 0;
box-shadow: none;
margin: 0;
}
}
/* 데스크톱 */
@media (min-width: 1024px) and (orientation: portrait) {
body {
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.phone-container {
width: 390px;
height: 844px;
max-height: 90vh;
border-radius: 25px;
box-shadow: 0 0 30px rgba(0,0,0,0.1);
margin: 0 auto;
}
}
/* 큰 데스크톱 화면 */
@media (min-width: 1440px) {
.phone-container {
width: 420px;
height: 900px;
}
}
.header {
padding: max(env(safe-area-inset-top), 20px) 20px 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
position: relative;
min-height: 90px;
}
.header-simple {
position: absolute;
top: 0;
left: 0;
right: 0;
padding: max(env(safe-area-inset-top), 20px) 20px 20px;
background: #fff;
color: #333;
border-bottom: 1px solid #e9ecef;
display: flex;
align-items: center;
min-height: 90px;
z-index: 100;
}
.back-btn {
position: absolute;
left: 20px;
background: none;
border: none;
font-size: clamp(20px, 5vw, 24px);
cursor: pointer;
color: inherit;
padding: 5px;
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
.back-btn:hover {
opacity: 0.7;
}
.title {
font-size: clamp(18px, 5vw, 24px);
font-weight: 700;
text-align: center;
flex: 1;
}
.subtitle {
font-size: clamp(14px, 4vw, 16px);
opacity: 0.9;
margin-top: 8px;
text-align: center;
}
/* 콘텐츠 영역 - 스크롤 문제 완전 해결 */
.content {
position: absolute;
top: 90px;
bottom: 70px;
left: 0;
right: 0;
padding: 10px 20px; /* 패딩 더 줄임 */
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
height: calc(100% - 160px);
display: flex;
flex-direction: column;
justify-content: flex-start; /* 위쪽 정렬 */
}
.content::-webkit-scrollbar {
display: none;
}
/* 태블릿과 데스크톱에서 더 나은 스크롤 경험 */
@media (min-width: 768px) {
.content {
padding: 20px 40px;
}
}
.logo {
text-align: center;
margin-bottom: 10px; /* 더 줄임 */
}
.logo-container {
width: clamp(70px, 12vw, 90px); /* 더 작게 */
height: clamp(70px, 12vw, 90px);
margin: 10px auto 15px; /* 마진 더 줄임 */
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.3);
}
.logo-icon {
font-size: clamp(30px, 8vw, 40px); /* 크기 줄임 */
color: white;
font-weight: 600;
}
.btn {
width: 100%;
padding: clamp(12px, 4vw, 16px);
border: none;
border-radius: 12px;
font-size: clamp(14px, 4vw, 16px);
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
margin-bottom: 16px;
min-height: 48px;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.3);
}
.btn-google {
background: #fff;
color: #333;
border: 2px solid #e9ecef;
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
}
.btn-google:hover {
border-color: #667eea;
}
.form-group {
margin-bottom: 20px;
}
.form-label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: #555;
font-size: clamp(14px, 3.5vw, 16px);
}
.form-input {
width: 100%;
padding: clamp(12px, 3.5vw, 14px);
border: 2px solid #e9ecef;
border-radius: 8px;
font-size: clamp(14px, 4vw, 16px);
transition: border-color 0.3s;
min-height: 48px;
}
.form-input:focus {
outline: none;
border-color: #667eea;
}
.form-select {
width: 100%;
padding: clamp(12px, 3.5vw, 14px);
border: 2px solid #e9ecef;
border-radius: 8px;
font-size: clamp(14px, 4vw, 16px);
background: white;
min-height: 48px;
}
.loading-container {
text-align: center;
padding: 20px; /* 패딩 줄임 */
}
.spinner {
width: clamp(50px, 12vw, 60px);
height: clamp(50px, 12vw, 60px);
border: 4px solid #e9ecef;
border-top: 4px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
margin: 0 auto 30px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.mission-card {
background: white;
border-radius: 12px;
padding: clamp(16px, 4vw, 20px);
margin-bottom: 16px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
border: 2px solid transparent;
cursor: pointer;
transition: all 0.3s;
position: relative;
}
.mission-card.selected {
border-color: #667eea;
background: #f8f9ff;
}
.mission-card h3 {
font-size: clamp(16px, 4.5vw, 18px);
margin-bottom: 8px;
color: #333;
padding-right: 50px;
}
.mission-card p {
color: #666;
font-size: clamp(12px, 3.5vw, 14px);
line-height: 1.5;
}
.checkbox {
position: absolute;
top: 20px;
right: 20px;
width: clamp(18px, 4vw, 20px);
height: clamp(18px, 4vw, 20px);
}
.chat-container {
position: absolute;
top: 90px;
bottom: 70px;
left: 0;
right: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.chat-messages {
flex: 1;
padding: clamp(16px, 4vw, 20px);
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
/* 스크롤 영역 명확히 설정 */
height: 100%;
min-height: 0;
}
.chat-messages::-webkit-scrollbar {
display: none;
}
.message {
margin-bottom: 16px;
padding: clamp(10px, 3vw, 12px) clamp(12px, 4vw, 16px);
border-radius: 18px;
max-width: 85%;
word-wrap: break-word;
font-size: clamp(14px, 3.5vw, 16px);
line-height: 1.4;
}
.message.user {
background: #667eea;
color: white;
margin-left: auto;
border-bottom-right-radius: 6px;
}
.message.ai {
background: #f1f3f4;
color: #333;
border-bottom-left-radius: 6px;
}
.message.celebration {
background: #e8f5e8;
border-left: 4px solid #4caf50;
color: #333;
}
.chat-input {
padding: clamp(16px, 4vw, 20px);
border-top: 1px solid #e9ecef;
background: white;
flex-shrink: 0;
/* 하단 네비게이션 때문에 margin-bottom 제거 */
}
.input-container {
display: flex;
gap: 10px;
align-items: center;
}
.chat-text-input {
flex: 1;
padding: clamp(10px, 3vw, 12px) clamp(12px, 4vw, 16px);
border: 2px solid #e9ecef;
border-radius: 20px;
outline: none;
font-size: clamp(14px, 4vw, 16px);
min-height: 44px;
}
.send-btn {
width: clamp(36px, 9vw, 40px);
height: clamp(36px, 9vw, 40px);
background: #667eea;
border: none;
border-radius: 50%;
color: white;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: clamp(14px, 4vw, 16px);
flex-shrink: 0;
}
/* 하단 네비게이션 - 반응형으로 완전히 재작성 */
.bottom-nav {
position: fixed;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 100vw;
max-width: 100vw;
background: white;
border-top: 1px solid #e9ecef;
display: flex;
padding: 12px 0;
padding-bottom: max(12px, env(safe-area-inset-bottom));
z-index: 100;
}
/* 태블릿 세로 모드 */
@media (min-width: 768px) and (max-width: 1023px) and (orientation: portrait) {
.bottom-nav {
width: 100vw;
max-width: 100vw;
position: fixed;
left: 0;
transform: none;
border-radius: 0;
}
}
/* 태블릿 가로 모드 */
@media (min-width: 768px) and (orientation: landscape) {
.bottom-nav {
width: 100vw;
max-width: 100vw;
position: fixed;
left: 0;
transform: none;
border-radius: 0;
}
}
/* 데스크톱 */
@media (min-width: 1024px) and (orientation: portrait) {
.bottom-nav {
width: 390px;
max-width: 390px;
position: absolute;
left: 0;
transform: none;
border-radius: 0 0 25px 25px;
}
}
/* 큰 데스크톱 */
@media (min-width: 1440px) {
.bottom-nav {
width: 420px;
max-width: 420px;
}
}
.nav-item {
flex: 1;
text-align: center;
padding: 8px;
cursor: pointer;
color: #999;
font-size: clamp(10px, 3vw, 12px);
transition: color 0.3s;
min-height: 44px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.nav-item.active {
color: #667eea;
}
.nav-item .icon {
font-size: clamp(16px, 5vw, 20px);
margin-bottom: 4px;
display: block;
}
.dashboard-card {
background: white;
border-radius: 12px;
padding: clamp(16px, 4vw, 20px);
margin-bottom: 16px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
}
.metric {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #f0f0f0;
font-size: clamp(14px, 3.5vw, 16px);
}
.metric:last-child {
border-bottom: none;
margin-bottom: 0;
}
.todo-item {
display: flex;
align-items: center;
padding: clamp(12px, 4vw, 16px);
background: white;
border-radius: 12px;
margin-bottom: 12px;
box-shadow: 0 2px 10px rgba(0,0,0,0.05);
}
.todo-text {
flex: 1;
font-size: clamp(14px, 4vw, 16px);
}
.progress-bar {
width: 100%;
height: 8px;
background: #e9ecef;
border-radius: 4px;
overflow: hidden;
margin: 8px 0;
}
.progress-fill {
height: 100%;
background: linear-gradient(90deg, #667eea, #764ba2);
transition: width 0.3s;
}
.chart-tabs {
display: flex;
gap: 4px;
margin-bottom: 16px;
background: #f8f9fa;
padding: 4px;
border-radius: 8px;
}
.chart-tab {
flex: 1;
padding: clamp(4px, 2vw, 6px) clamp(6px, 2vw, 8px);
border: none;
border-radius: 6px;
background: transparent;
color: #666;
font-size: clamp(9px, 2.5vw, 11px);
cursor: pointer;
transition: all 0.3s;
min-height: 32px;
}
.chart-tab.active {
background: #667eea;
color: white;
}
.chart-container {
height: clamp(160px, 40vw, 200px);
background: #fafafa;
border-radius: 8px;
padding: clamp(12px, 4vw, 16px);
position: relative;
overflow: hidden;
}
.goal-counter {
display: flex;
align-items: center;
gap: 12px;
}
.goal-count {
font-size: clamp(12px, 3.5vw, 14px);
color: #666;
}
.goal-btn {
width: clamp(28px, 7vw, 32px);
height: clamp(28px, 7vw, 32px);
border: 2px solid #667eea;
border-radius: 50%;
background: white;
color: #667eea;
cursor: pointer;
font-size: clamp(14px, 4vw, 18px);
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
flex-shrink: 0;
}
.goal-btn.completed {
background: #667eea;
color: white;
}
.achievement-banner {
background: linear-gradient(135deg, #667eea, #764ba2);
color: white;
padding: clamp(16px, 5vw, 20px);
border-radius: 12px;
margin-top: 20px;
text-align: center;
}
.achievement-banner h3 {
margin-bottom: 8px;
font-size: clamp(16px, 4.5vw, 18px);
}
.achievement-banner p {
opacity: 0.9;
font-size: clamp(12px, 3.5vw, 14px);
}
.period-buttons {
display: flex;
gap: 8px;
margin-bottom: 16px;
}
.period-btn {
flex: 1;
padding: clamp(6px, 2vw, 8px);
border: 1px solid #667eea;
border-radius: 6px;
background: white;
color: #667eea;
font-size: clamp(10px, 3vw, 12px);
cursor: pointer;
transition: all 0.3s;
min-height: 36px;
}
.period-btn.active {
background: #667eea;
color: white;
}
.overall-rate {
font-size: clamp(32px, 10vw, 48px);
font-weight: bold;
color: #667eea;
margin-bottom: 8px;
text-align: center;
}
.daily-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: clamp(4px, 2vw, 8px);
margin-bottom: 16px;
}
.day-header {
text-align: center;
font-size: clamp(10px, 3vw, 12px);
color: #666;
}
.day-item {
width: clamp(24px, 6vw, 32px);
height: clamp(24px, 6vw, 32px);
border-radius: 6px;
margin: 0 auto;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: clamp(9px, 3vw, 12px);
}
.day-item.completed {
background: #667eea;
}
.day-item.incomplete {
background: #e9ecef;
color: #666;
}
.info-box {
background: #f8f9ff;
padding: clamp(12px, 4vw, 16px);
border-radius: 12px;
margin-top: 10px; /* 마진 더 줄임 */
}
.info-box p {
color: #667eea;
font-weight: 600;
margin-bottom: 6px; /* 마진 줄임 */
text-align: center;
font-size: clamp(13px, 3.5vw, 15px); /* 폰트 크기 줄임 */
}
.info-box .subtitle {
color: #666;
font-size: clamp(11px, 3vw, 13px); /* 폰트 크기 줄임 */
opacity: 1;
}
.btn-secondary {
background: #f8f9fa;
color: #666;
}
.dashboard-header-btn {
position: absolute;
right: 20px;
background: none;
border: none;
font-size: clamp(18px, 5vw, 20px);
cursor: pointer;
min-width: 44px;
min-height: 44px;
display: flex;
align-items: center;
justify-content: center;
}
.dashboard-content {
padding-bottom: clamp(100px, 25vw, 120px) !important;
}
.history-btn {
background: none;
border: 1px solid #667eea;
color: #667eea;
padding: clamp(4px, 2vw, 6px) clamp(8px, 3vw, 12px);
border-radius: 6px;
font-size: clamp(10px, 3vw, 12px);
cursor: pointer;
min-height: 32px;
}
.section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
/* 접근성 개선 */
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
}
}
/* 터치 디바이스 최적화 */
@media (hover: none) and (pointer: coarse) {
.btn:hover,
.btn-primary:hover,
.btn-google:hover,
.back-btn:hover {
transform: none;
box-shadow: none;
}
.btn:active,
.btn-primary:active {
transform: translateY(1px);
}
}
/* 매우 작은 화면 최적화 */
@media (max-width: 320px) {
.content {
padding: 12px 10px;
}
.mission-card {
padding: 12px;
}
.chat-messages,
.chat-input {
padding: 12px;
}
}

160
src/App.js Normal file
View File

@ -0,0 +1,160 @@
//* src/App.js
import React, { useState } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import LoginPage from './pages/LoginPage';
import RegisterPage from './pages/RegisterPage';
import LoadingPage from './pages/LoadingPage';
import MissionPage from './pages/MissionPage';
import ChatPage from './pages/ChatPage';
import DashboardPage from './pages/DashboardPage';
import GoalsPage from './pages/GoalsPage';
import HistoryPage from './pages/HistoryPage';
import OAuthCallbackPage from './pages/OAuthCallbackPage';
function App() {
const [user, setUser] = useState(null);
const [selectedMissions, setSelectedMissions] = useState([]);
const [healthData, setHealthData] = useState(null); // 건강검진 데이터
const [healthDiagnosis, setHealthDiagnosis] = useState([]); // 건강진단 3줄 요약
const [aiMissions, setAiMissions] = useState([]); // AI 추천 미션들
const [goalData, setGoalData] = useState({
water: { current: 3, target: 8 },
walk: { current: 10, target: 10 },
stretch: { current: 5, target: 5 },
snack: { current: 0, target: 1 },
sleep: { current: 0, target: 1 }
});
// 🔍 상태 변경 감지 함수들
const setHealthDataWithLog = (data) => {
console.log('=== App.js setHealthData 호출 ===');
console.log('받은 healthData:', data);
console.log('데이터 타입:', typeof data);
if (data) {
console.log('데이터 키들:', Object.keys(data));
}
setHealthData(data);
};
const setHealthDiagnosisWithLog = (diagnosis) => {
console.log('=== App.js setHealthDiagnosis 호출 ===');
console.log('받은 healthDiagnosis:', diagnosis);
console.log('진단 타입:', typeof diagnosis);
console.log('진단 배열 여부:', Array.isArray(diagnosis));
console.log('진단 길이:', diagnosis?.length);
setHealthDiagnosis(diagnosis);
};
const setAiMissionsWithLog = (missions) => {
console.log('=== App.js setAiMissions 호출 ===');
console.log('받은 aiMissions:', missions);
console.log('미션 타입:', typeof missions);
console.log('미션 배열 여부:', Array.isArray(missions));
console.log('미션 개수:', missions?.length);
setAiMissions(missions);
};
const setGoalDataWithLog = (goals) => {
console.log('=== App.js setGoalData 호출 ===');
console.log('이전 goalData:', goalData);
console.log('받은 goalData:', goals);
// ✅ goalData 변경 상세 로깅
if (goals && Object.keys(goals).length > 0) {
console.log('목표 개수:', Object.keys(goals).length);
Object.keys(goals).forEach(goalType => {
const goal = goals[goalType];
console.log(`${goalType}:`, {
current: goal?.current,
target: goal?.target,
goalName: goal?.goalName,
missionId: goal?.missionId,
completedToday: goal?.completedToday
});
});
}
setGoalData(goals);
};
// 🔍 현재 상태 로깅 (렌더링 시마다)
console.log('=== App.js 현재 상태 ===');
console.log('App healthData:', healthData);
console.log('App healthDiagnosis:', healthDiagnosis);
console.log('App aiMissions:', aiMissions);
console.log('App goalData:', goalData);
console.log('App user:', user);
return (
<div className="App">
<Router>
<div className="phone-container">
<Routes>
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="/login" element={<LoginPage setUser={setUser} />} />
<Route path="/register" element={<RegisterPage setUser={setUser} />} />
<Route
path="/loading"
element={
<LoadingPage
setHealthData={setHealthDataWithLog}
setGoalData={setGoalDataWithLog}
setAiMissions={setAiMissionsWithLog}
setHealthDiagnosis={setHealthDiagnosisWithLog}
user={user}
/>
}
/>
<Route path="/login/oauth2/code/google" element={<OAuthCallbackPage setUser={setUser} />} />
<Route
path="/mission"
element={
<MissionPage
selectedMissions={selectedMissions}
setSelectedMissions={setSelectedMissions}
setGoalData={setGoalDataWithLog} // 디버깅 함수로 변경
aiMissions={aiMissions} // AI 추천 미션 전달
healthData={healthData} // 건강 데이터 전달
healthDiagnosis={healthDiagnosis} // 건강진단 3줄 요약 전달
/>
}
/>
<Route path="/chat" element={<ChatPage user={user} />} />
<Route
path="/goals"
element={
<GoalsPage
goalData={goalData}
setGoalData={setGoalDataWithLog} // 디버깅 함수로 변경
/>
}
/>
<Route
path="/dashboard"
element={
<DashboardPage
user={user}
healthData={healthData}
healthDiagnosis={healthDiagnosis}
goalData={goalData}
/>
}
/>
{/* ✅ HistoryPage에 goalData props 추가 */}
<Route
path="/history"
element={
<HistoryPage
goalData={goalData}
/>
}
/>
</Routes>
</div>
</Router>
</div>
);
}
export default App;

60
src/App.js.backup Normal file
View File

@ -0,0 +1,60 @@
//* src/App.js
import React, { useState } from 'react';
import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom';
import LoginPage from './pages/LoginPage';
import RegisterPage from './pages/RegisterPage';
import LoadingPage from './pages/LoadingPage';
import MissionPage from './pages/MissionPage';
import ChatPage from './pages/ChatPage';
import DashboardPage from './pages/DashboardPage';
import GoalsPage from './pages/GoalsPage';
import HistoryPage from './pages/HistoryPage';
import { GoogleOAuthProvider } from '@react-oauth/google';
function App() {
const googleClientId = window.__runtime_config__?.GOOGLE_CLIENT_ID;
const [user, setUser] = useState(null);
const [healthData, setHealthData] = useState(null);
const [selectedMissions, setSelectedMissions] = useState([]);
const [goalData, setGoalData] = useState({
water: { current: 3, target: 8 },
walk: { current: 10, target: 10 },
stretch: { current: 5, target: 5 },
snack: { current: 0, target: 1 },
sleep: { current: 0, target: 1 }
});
return (
<GoogleOAuthProvider clientId={googleClientId}>
<div className="App">
<Router>
<div className="phone-container">
<Routes>
{/* Navigate 컴포넌트는 props를 받지 않습니다 */}
<Route path="/" element={<Navigate to="/login" replace />} />
<Route path="/login" element={<LoginPage setUser={setUser} />} />
<Route path="/register" element={<RegisterPage setUser={setUser} />} />
<Route
path="/loading"
element={
<LoadingPage
setHealthData={setHealthData}
setGoalData={setGoalData}
user={user}
/>
}
/>
<Route path="/mission" element={<MissionPage selectedMissions={selectedMissions} setSelectedMissions={setSelectedMissions} />} />
<Route path="/chat" element={<ChatPage user={user} />} />
<Route path="/dashboard" element={<DashboardPage user={user} healthData={healthData} />} />
<Route path="/goals" element={<GoalsPage goalData={goalData} setGoalData={setGoalData} />} />
<Route path="/history" element={<HistoryPage />} />
</Routes>
</div>
</Router>
</div>
</GoogleOAuthProvider>
);
}
export default App;

35
src/App.jsx Normal file
View File

@ -0,0 +1,35 @@
import { useState } from 'react'
import reactLogo from './assets/react.svg'
import viteLogo from '/vite.svg'
import './App.css'
function App() {
const [count, setCount] = useState(0)
return (
<>
<div>
<a href="https://vite.dev" target="_blank">
<img src={viteLogo} className="logo" alt="Vite logo" />
</a>
<a href="https://react.dev" target="_blank">
<img src={reactLogo} className="logo react" alt="React logo" />
</a>
</div>
<h1>Vite + React</h1>
<div className="card">
<button onClick={() => setCount((count) => count + 1)}>
count is {count}
</button>
<p>
Edit <code>src/App.jsx</code> and save to test HMR
</p>
</div>
<p className="read-the-docs">
Click on the Vite and React logos to learn more
</p>
</>
)
}
export default App

1
src/assets/react.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,48 @@
import React from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
function BottomNav() {
const navigate = useNavigate();
const location = useLocation();
const navItems = [
{ path: '/chat', icon: '💬', label: '채팅' },
{ path: '/dashboard', icon: '📊', label: '대시보드' },
{ path: '/goals', icon: '🎯', label: '목표' }
];
return (
<div style={{
position: 'fixed',
bottom: 0,
left: 0,
right: 0,
backgroundColor: 'white',
borderTop: '1px solid #e9ecef',
display: 'flex',
padding: '12px 0',
zIndex: 1000,
boxShadow: '0 -2px 10px rgba(0,0,0,0.1)'
}}>
{navItems.map(item => (
<div
key={item.path}
style={{
flex: 1,
textAlign: 'center',
padding: '8px',
cursor: 'pointer',
color: location.pathname === item.path ? '#667eea' : '#999',
fontSize: '12px'
}}
onClick={() => navigate(item.path)}
>
<div style={{ fontSize: '20px', marginBottom: '4px' }}>{item.icon}</div>
<div>{item.label}</div>
</div>
))}
</div>
);
}
export default BottomNav;

28
src/components/Header.js Normal file
View File

@ -0,0 +1,28 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
function Header({ title, showBack = false, backPath = null, children }) {
const navigate = useNavigate();
const handleBack = () => {
if (backPath) {
navigate(backPath);
} else {
navigate(-1);
}
};
return (
<div className="header-simple">
{showBack && (
<button className="back-btn" onClick={handleBack}>
</button>
)}
<div className="title">{title}</div>
{children}
</div>
);
}
export default Header;

View File

@ -0,0 +1,12 @@
import React from 'react';
function LoadingSpinner({ message = "로딩 중..." }) {
return (
<div className="loading-container">
<div className="spinner"></div>
<h3 style={{ marginBottom: '16px' }}>{message}</h3>
</div>
);
}
export default LoadingSpinner;

View File

@ -0,0 +1,21 @@
import React from 'react';
function MissionCard({ mission, isSelected, onToggle }) {
return (
<div
className={`mission-card ${isSelected ? 'selected' : ''}`}
onClick={onToggle}
>
<input
type="checkbox"
className="checkbox"
checked={isSelected}
onChange={() => {}}
/>
<h3>{mission.title}</h3>
<p>{mission.description}</p>
</div>
);
}
export default MissionCard;

68
src/index.css Normal file
View File

@ -0,0 +1,68 @@
:root {
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color-scheme: light dark;
color: rgba(255, 255, 255, 0.87);
background-color: #242424;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
a {
font-weight: 500;
color: #646cff;
text-decoration: inherit;
}
a:hover {
color: #535bf2;
}
body {
margin: 0;
display: flex;
place-items: center;
min-width: 320px;
min-height: 100vh;
}
h1 {
font-size: 3.2em;
line-height: 1.1;
}
button {
border-radius: 8px;
border: 1px solid transparent;
padding: 0.6em 1.2em;
font-size: 1em;
font-weight: 500;
font-family: inherit;
background-color: #1a1a1a;
cursor: pointer;
transition: border-color 0.25s;
}
button:hover {
border-color: #646cff;
}
button:focus,
button:focus-visible {
outline: 4px auto -webkit-focus-ring-color;
}
@media (prefers-color-scheme: light) {
:root {
color: #213547;
background-color: #ffffff;
}
a:hover {
color: #747bff;
}
button {
background-color: #f9f9f9;
}
}

11
src/index.js Normal file
View File

@ -0,0 +1,11 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import './App.css';
import App from './App';
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

10
src/main.jsx Normal file
View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.jsx'
createRoot(document.getElementById('root')).render(
<StrictMode>
<App />
</StrictMode>,
)

386
src/pages/ChatPage.js Normal file
View File

@ -0,0 +1,386 @@
//* src/pages/ChatPage.js
import React, { useState, useEffect, useRef } from 'react';
import BottomNav from '../components/BottomNav';
function ChatPage({ user }) {
const messagesEndRef = useRef(null);
const [messages, setMessages] = useState([]);
const [inputText, setInputText] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isFirstLoad, setIsFirstLoad] = useState(true);
// 컴포넌트 마운트 시 환영 메시지 표시
useEffect(() => {
initializeChat();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // initializeChat은 마운트 시에만 실행되어야 함
// 새 메시지가 추가될 때마다 스크롤을 맨 아래로
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
// 채팅 초기화 함수 수정
const initializeChat = async () => {
try {
// 기본 환영 메시지 (항상 맨 위에 표시)
const welcomeMessage = {
id: 'welcome',
type: 'ai',
text: '안녕하세요! 저는 회원님의 AI 건강 코치입니다. 건강검진 결과에 대해 궁금한 점이 있으시면 언제든 물어보세요! 🏥',
timestamp: new Date().toISOString()
};
// 기존 대화 히스토리 로드 시도
const loadedMessages = await loadChatHistory();
if (!loadedMessages || loadedMessages.length === 0) {
// 히스토리가 없으면 환영 메시지만 표시
setMessages([welcomeMessage]);
console.log('첫 방문 - 환영 메시지만 표시합니다.');
} else {
// 히스토리가 있으면 환영 메시지 + 히스토리 메시지들 표시
setMessages([welcomeMessage, ...loadedMessages]);
console.log(`환영 메시지와 ${loadedMessages.length}개의 히스토리 메시지를 불러왔습니다.`);
}
} catch (error) {
console.error('채팅 초기화 중 예상치 못한 오류:', error);
// 초기화 실패 시 기본 환영 메시지만 표시
setMessages([{
id: 'welcome',
type: 'ai',
text: '안녕하세요! 저는 회원님의 AI 건강 코치입니다. 건강에 대해 궁금한 점이 있으시면 언제든 물어보세요! 🏥',
timestamp: new Date().toISOString()
}]);
} finally {
setIsFirstLoad(false);
}
};
// 대화 히스토리 로드 함수 수정 (메시지 배열을 반환하도록)
const loadChatHistory = async () => {
try {
const token = localStorage.getItem('authToken');
const storedUserId = localStorage.getItem('userId');
const userId = storedUserId ? parseInt(storedUserId) : 1;
console.log(`사용자 ${userId}의 대화 히스토리를 불러오는 중...`);
const response = await fetch(`${window.__runtime_config__?.INTELLIGENCE_URL}/chat/history?user_id=${userId}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
if (response.status === 404 || response.status === 422) {
console.log('대화 기록이 없습니다. 새로운 대화를 시작합니다.');
return []; // 빈 배열 반환
}
throw new Error(`히스토리 API 오류: ${response.status}`);
}
const result = await response.json();
const chatHistory = result.chat_history || [];
if (Array.isArray(chatHistory) && chatHistory.length > 0) {
// 메시지를 시간순으로 정렬하고 사용자/AI 메시지 분리
const allMessages = [];
chatHistory
.sort((a, b) => new Date(a.created_at) - new Date(b.created_at)) // 시간순 정렬 (오래된 것부터)
.forEach((record) => {
// AI 응답이 오류 메시지인지 먼저 확인
const hasErrorResponse = record.response_content && (
record.response_content.includes('💭 응답을 생성하고 있습니다') ||
record.response_content.includes('❌ 죄송합니다. 응답 생성 중 오류가 발생했습니다') ||
record.response_content.includes('죄송합니다. 일시적인 오류가 발생했습니다') ||
record.response_content.includes('건강 상담 처리 실패') ||
record.response_content.includes('건강검진 데이터를 찾을 수 없습니다') ||
record.response_content.includes('AI 채팅 API 오류')
);
// 오류 응답이 있는 경우 사용자 질문과 AI 응답 모두 제외
if (hasErrorResponse) {
return; // 이 record 전체를 건너뛰기
}
// 사용자 메시지 (message_content가 있는 경우)
if (record.message_content && record.message_content.trim() !== '') {
allMessages.push({
id: `user_${record.message_id}`,
type: 'user',
text: record.message_content,
timestamp: record.created_at
});
}
// AI 응답 (정상적인 응답인 경우만)
if (record.response_content && record.response_content.trim() !== '') {
allMessages.push({
id: `ai_${record.message_id}`,
type: 'ai',
text: record.response_content,
timestamp: record.created_at
});
}
});
console.log(`${allMessages.length}개의 메시지를 불러왔습니다.`);
return allMessages; // 메시지 배열 반환
} else {
console.log('대화 기록이 비어있습니다.');
return []; // 빈 배열 반환
}
} catch (error) {
console.error('대화 히스토리 로드 중 오류:', error);
return []; // 오류 시 빈 배열 반환
}
};
// AI 채팅 API 호출 함수
const sendMessageToAI = async (message) => {
try {
const token = localStorage.getItem('authToken');
const storedUserId = localStorage.getItem('userId');
const userId = storedUserId ? parseInt(storedUserId) : 1; // 로컬스토리지에서 가져오되 없으면 1
console.log('💬 채팅 API 요청:', {
url: `${window.__runtime_config__?.INTELLIGENCE_URL}/chat/consultation`,
userId,
message: message.substring(0, 50) + '...'
});
// ✅ INTELLIGENCE_URL 사용으로 수정
const response = await fetch(`${window.__runtime_config__?.INTELLIGENCE_URL}/chat/consultation`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
message: message,
user_id: userId // integer 타입으로 전송
})
});
console.log('📡 채팅 API 응답 상태:', response.status, response.statusText);
if (!response.ok) {
const errorText = await response.text();
console.error('❌ 채팅 API 오류:', response.status, errorText);
throw new Error(`AI 채팅 API 오류: ${response.status}`);
}
const result = await response.json();
console.log('✅ 채팅 API 성공:', result);
return result.response || result.message || '죄송합니다. 응답을 생성할 수 없습니다.';
} catch (error) {
console.error('AI 채팅 API 호출 오류:', error);
return '죄송합니다. 일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요.';
}
};
// 메시지 전송 핸들러
// 메시지 전송 핸들러
const handleSend = async () => {
if (inputText.trim() && !isLoading) {
const userMessage = {
id: messages.length + 1,
type: 'user',
text: inputText.trim(),
timestamp: new Date().toISOString()
};
// 사용자 메시지 즉시 추가
setMessages(prev => [...prev, userMessage]);
setInputText('');
setIsLoading(true);
try {
// AI 응답 요청
const aiResponseText = await sendMessageToAI(userMessage.text);
// AI 응답 메시지 추가
const aiMessage = {
id: messages.length + 2,
type: 'ai',
text: aiResponseText,
timestamp: new Date().toISOString()
};
setMessages(prev => [...prev, aiMessage]);
} catch (error) {
console.error('메시지 전송 오류:', error);
// 오류 시 기본 응답 추가
const errorMessage = {
id: messages.length + 2,
type: 'ai',
text: '죄송합니다. 일시적인 오류가 발생했습니다. 잠시 후 다시 시도해주세요.',
timestamp: new Date().toISOString()
};
setMessages(prev => [...prev, errorMessage]);
} finally {
setIsLoading(false);
}
}
};
const handleKeyPress = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
if (isFirstLoad) {
return (
<div className="phone-container">
<div className="header-simple">
<div className="title">AI 건강 코치</div>
</div>
<div className="loading-container" style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
height: '60vh',
flexDirection: 'column'
}}>
<div className="loading-spinner"></div>
<p style={{ marginTop: '20px', color: '#666' }}>AI 건강 코치를 준비하는 ...</p>
</div>
<BottomNav currentPage="chat" />
</div>
);
}
return (
<div className="phone-container">
{/* 헤더 */}
<div className="header-simple">
<div className="title">AI 건강 코치</div>
</div>
{/* 채팅 컨테이너 */}
<div className="chat-container">
{/* 메시지 영역 */}
<div className="chat-messages">
{messages.map(message => (
<div
key={message.id}
className={`message ${message.type} ${message.className || ''}`}
>
{message.text}
{message.timestamp && (
<div className="message-time" style={{
fontSize: '11px',
color: '#999',
marginTop: '4px',
textAlign: message.type === 'user' ? 'right' : 'left'
}}>
{new Date(message.timestamp).toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit'
})}
</div>
)}
</div>
))}
{/* AI 응답 로딩 표시 */}
{isLoading && (
<div className="message ai">
<div className="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
AI가 답변을 생성하고 있습니다...
</div>
)}
{/* 스크롤 위치 참조용 div */}
<div ref={messagesEndRef} />
</div>
{/* 입력 영역 */}
<div className="chat-input">
<div className="input-container">
<input
type="text"
value={inputText}
onChange={(e) => setInputText(e.target.value)}
onKeyPress={handleKeyPress}
placeholder="건강에 대해 궁금한 점을 물어보세요..."
className="chat-text-input"
disabled={isLoading}
/>
<button
onClick={handleSend}
className="send-btn"
disabled={isLoading || !inputText.trim()}
style={{
opacity: (isLoading || !inputText.trim()) ? 0.5 : 1,
cursor: (isLoading || !inputText.trim()) ? 'not-allowed' : 'pointer'
}}
>
{isLoading ? '...' : '→'}
</button>
</div>
</div>
</div>
<BottomNav currentPage="chat" />
{/* 추가 스타일 */}
<style>{`
.typing-indicator {
display: inline-flex;
gap: 4px;
margin-right: 8px;
}
.typing-indicator span {
height: 8px;
width: 8px;
background: #999;
border-radius: 50%;
animation: typing 1.4s infinite ease-in-out;
}
.typing-indicator span:nth-child(1) { animation-delay: -0.32s; }
.typing-indicator span:nth-child(2) { animation-delay: -0.16s; }
@keyframes typing {
0%, 80%, 100% { transform: scale(0.8); opacity: 0.5; }
40% { transform: scale(1.2); opacity: 1; }
}
.loading-spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #667eea;
border-radius: 50%;
width: 30px;
height: 30px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
`}</style>
</div>
);
}
export default ChatPage;

521
src/pages/DashboardPage.js Normal file
View File

@ -0,0 +1,521 @@
// DashboardPage.js - API 데이터 매핑 함수 추가 (기존 코드 수정 최소화)
import React, { useState } from 'react';
import Header from '../components/Header';
import BottomNav from '../components/BottomNav';
function DashboardPage({ healthData, healthDiagnosis, goalData }) {
const [activeChart, setActiveChart] = useState('bloodPressure');
// 🔍 받은 props 디버깅
console.log('=== DashboardPage Props 디버깅 ===');
console.log('1. healthData:', healthData);
console.log('2. healthData 타입:', typeof healthData);
console.log('3. healthData null 여부:', healthData === null);
console.log('4. healthData undefined 여부:', healthData === undefined);
console.log('5. healthDiagnosis:', healthDiagnosis);
console.log('6. healthDiagnosis 타입:', typeof healthDiagnosis);
console.log('7. healthDiagnosis 배열 여부:', Array.isArray(healthDiagnosis));
console.log('8. healthDiagnosis 길이:', healthDiagnosis?.length);
// DashboardPage.js 맨 위에 추가
console.log('=== DashboardPage에서 받은 실제 healthData ===');
console.log('healthData 전체:', healthData);
console.log('healthData JSON:', JSON.stringify(healthData, null, 2));
if (healthData) {
console.log('최상위 키들:', Object.keys(healthData));
// 가능한 모든 하위 구조 확인
Object.keys(healthData).forEach(key => {
console.log(`${key}:`, healthData[key]);
if (typeof healthData[key] === 'object' && healthData[key] !== null) {
console.log(`${key}의 키들:`, Object.keys(healthData[key]));
}
});
}
// ✅ API 데이터를 기존 구조에 맞게 매핑하는 함수
const mapApiToExpectedStructure = (apiData) => {
if (!apiData) return null;
// API 구조: userInfo, recentHealthProfile, healthProfiles
// 기존 코드 구조: basicInfo, latestRecord, trends
const userInfo = apiData.userInfo || {};
const recentProfile = apiData.recentHealthProfile || {};
const profiles = apiData.healthProfiles || [];
// trends 데이터 생성 (healthProfiles 배열에서)
const generateTrends = (profiles) => {
if (profiles.length === 0) {
// 기본 trends 반환
return {
bloodPressure: [
{ year: "2021", value: "120/80", y: 100 },
{ year: "2022", value: "125/82", y: 110 },
{ year: "2023", value: "130/85", y: 120 },
{ year: "2024", value: "125/83", y: 115 },
{ year: "2025", value: "123/81", y: 105 }
],
cholesterol: [
{ year: "2021", value: "200", y: 100 },
{ year: "2022", value: "210", y: 110 },
{ year: "2023", value: "220", y: 120 },
{ year: "2024", value: "225", y: 125 },
{ year: "2025", value: "232", y: 130 }
],
weight: [
{ year: "2021", value: "65kg", y: 120 },
{ year: "2022", value: "63kg", y: 115 },
{ year: "2023", value: "61kg", y: 110 },
{ year: "2024", value: "60kg", y: 105 },
{ year: "2025", value: "59kg", y: 100 }
]
};
}
// 실제 프로필 데이터에서 trends 생성
const sortedProfiles = profiles.sort((a, b) => a.referenceYear - b.referenceYear).slice(-5);
return {
bloodPressure: sortedProfiles.map((profile, index) => ({
year: profile.referenceYear?.toString() || "2024",
value: `${profile.systolicBp || 120}/${profile.diastolicBp || 80}`,
y: 100 + (index * 10)
})),
cholesterol: sortedProfiles.map((profile, index) => ({
year: profile.referenceYear?.toString() || "2024",
value: (profile.totalCholesterol || 200).toString(),
y: 100 + (index * 10)
})),
weight: sortedProfiles.map((profile, index) => ({
year: profile.referenceYear?.toString() || "2024",
value: `${profile.weight || 70}kg`,
y: 100 + (index * 10)
}))
};
};
return {
basicInfo: {
age: userInfo.age,
gender_code: userInfo.genderCode || (userInfo.gender === "남성" ? 1 : 2),
occupation: userInfo.occupation
},
latestRecord: {
reference_year: recentProfile.referenceYear,
height: recentProfile.height,
weight: recentProfile.weight,
waist_circumference: recentProfile.waistCircumference,
visual_acuity_left: recentProfile.visualAcuityLeft,
visual_acuity_right: recentProfile.visualAcuityRight,
hearing_left: recentProfile.hearingLeft,
hearing_right: recentProfile.hearingRight,
systolic_bp: recentProfile.systolicBp,
diastolic_bp: recentProfile.diastolicBp,
fasting_glucose: recentProfile.fastingGlucose,
total_cholesterol: recentProfile.totalCholesterol,
triglyceride: recentProfile.triglyceride,
hdl_cholesterol: recentProfile.hdlCholesterol,
ldl_cholesterol: recentProfile.ldlCholesterol,
hemoglobin: recentProfile.hemoglobin,
urine_protein: recentProfile.urineProtein,
serum_creatinine: recentProfile.serumCreatinine,
ast: recentProfile.ast,
alt: recentProfile.alt,
gamma_gtp: recentProfile.gammaGtp,
smoking_status: recentProfile.smokingStatus,
drinking_status: recentProfile.drinkingStatus
},
trends: generateTrends(profiles)
};
};
// healthData 세부 구조 확인
if (healthData) {
console.log('9. healthData 키들:', Object.keys(healthData));
console.log('10. healthData.basicInfo:', healthData.basicInfo);
console.log('11. healthData.latestRecord:', healthData.latestRecord);
console.log('12. healthData.trends:', healthData.trends);
} else {
console.log('9. healthData가 null/undefined이므로 기본값 사용됨');
}
// healthDiagnosis 내용 확인
if (healthDiagnosis && healthDiagnosis.length > 0) {
console.log('13. healthDiagnosis 내용:', healthDiagnosis);
} else {
console.log('13. healthDiagnosis가 비어있으므로 기본값 사용됨');
}
// healthData가 없으면 기본값 사용(테스트용)
const defaultHealthData = {
basicInfo: { age: 32, gender_code: 1, occupation: "IT개발" },
latestRecord: {
reference_year: 2024,
height: 175,
weight: 70,
waist_circumference: 85,
visual_acuity_left: 1.0,
visual_acuity_right: 1.0,
hearing_left: 25,
hearing_right: 25,
systolic_bp: 140,
diastolic_bp: 90,
fasting_glucose: 95,
total_cholesterol: 220,
triglyceride: 150,
hdl_cholesterol: 50,
ldl_cholesterol: 130,
hemoglobin: 14.5,
urine_protein: 0,
serum_creatinine: 1.0,
ast: 25,
alt: 30,
gamma_gtp: 25,
smoking_status: 0,
drinking_status: 0
},
trends: {
bloodPressure: [
{ year: "2020", value: "145/88", y: 120 },
{ year: "2021", value: "142/85", y: 100 },
{ year: "2022", value: "144/87", y: 110 },
{ year: "2023", value: "141/89", y: 95 },
{ year: "2024", value: "140/90", y: 105 }
],
cholesterol: [
{ year: "2020", value: "240", y: 100 },
{ year: "2021", value: "235", y: 90 },
{ year: "2022", value: "230", y: 85 },
{ year: "2023", value: "225", y: 75 },
{ year: "2024", value: "220", y: 80 }
],
weight: [
{ year: "2020", value: "75kg", y: 110 },
{ year: "2021", value: "73kg", y: 105 },
{ year: "2022", value: "71kg", y: 100 },
{ year: "2023", value: "70kg", y: 98 },
{ year: "2024", value: "68kg", y: 95 }
]
}
};
// 기본 건강진단 3줄 요약 (LoadingPage와 동일)
const defaultHealthDiagnosis = [
"혈압과 콜레스테롤 수치가 주의 범위에 있어 적극적인 관리가 필요해요!",
"IT 직업 특성상 장시간 앉아있는 생활 패턴으로 인한 건강 리스크가 보여요!",
"하지만 지금 시작하면 충분히 개선 가능하니 함께 건강해져요!"
];
// 🔍 실제 사용할 데이터 결정 과정 디버깅
// ✅ API 데이터가 있으면 매핑해서 사용, 없으면 기본값 사용
const data = healthData ? mapApiToExpectedStructure(healthData) : defaultHealthData;
const diagnosis = healthDiagnosis || defaultHealthDiagnosis;
console.log('14. 최종 사용할 data:', data);
console.log('15. 최종 사용할 diagnosis:', diagnosis);
console.log('16. 기본값 사용 여부 - healthData:', !healthData);
console.log('17. 기본값 사용 여부 - healthDiagnosis:', !healthDiagnosis || healthDiagnosis.length === 0);
console.log('=== DashboardPage 디버깅 끝 ===');
// BMI 계산 함수
const calculateBMI = (height, weight) => {
if (!height || !weight) return 24.2;
const heightInM = height / 100;
const bmi = weight / (heightInM * heightInM);
return Math.round(bmi * 10) / 10;
};
// BMI 상태 판정
const getBMIStatus = (bmi) => {
if (bmi < 18.5) return { text: "저체중", color: "#3498db" };
if (bmi < 23) return { text: "정상", color: "#27ae60" };
if (bmi < 25) return { text: "과체중", color: "#f39c12" };
return { text: "비만", color: "#e74c3c" };
};
// 혈압 상태 판정
const getBloodPressureStatus = (systolic, diastolic) => {
if (systolic >= 140 || diastolic >= 90) {
return { color: "#e74c3c" }; // 고혈압
} else if (systolic >= 130 || diastolic >= 80) {
return { color: "#f39c12" }; // 주의
}
return { color: "#27ae60" }; // 정상
};
// 콜레스테롤 상태 판정
const getCholesterolStatus = (cholesterol) => {
if (cholesterol >= 240) return { color: "#e74c3c" };
if (cholesterol >= 200) return { color: "#f39c12" };
return { color: "#27ae60" };
};
// 혈당 상태 판정
const getGlucoseStatus = (glucose) => {
if (glucose >= 126) return { color: "#e74c3c" };
if (glucose >= 100) return { color: "#f39c12" };
return { color: "#27ae60" };
};
const chartData = {
bloodPressure: {
label: '혈압',
color: '#667eea',
data: data.trends?.bloodPressure || defaultHealthData.trends.bloodPressure
},
cholesterol: {
label: '콜레스테롤',
color: '#f39c12',
data: data.trends?.cholesterol || defaultHealthData.trends.cholesterol
},
weight: {
label: '체중',
color: '#e74c3c',
data: data.trends?.weight || defaultHealthData.trends.weight
}
};
const handleChartChange = (chartType) => {
setActiveChart(chartType);
};
const renderChart = () => {
const chartInfo = chartData[activeChart];
const points = chartInfo.data.map((item, index) => `${20 + index * 60},${item.y}`).join(' ');
return (
<div className="chart-container">
<svg width="100%" height="100%">
{/* 격자 라인 */}
<defs>
<pattern id="grid" width="40" height="30" patternUnits="userSpaceOnUse">
<path d="M 40 0 L 0 0 0 30" fill="none" stroke="#e9ecef" strokeWidth="1"/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
{/* 라인 차트 */}
<polyline
fill="none"
stroke={chartInfo.color}
strokeWidth="3"
points={points}
strokeLinecap="round"
/>
{/* 데이터 포인트 */}
{chartInfo.data.map((item, index) => (
<g key={index}>
<circle
cx={20 + index * 60}
cy={item.y}
r="4"
fill={chartInfo.color}
/>
<text
x={20 + index * 60}
y="160"
textAnchor="middle"
fontSize="10"
fill="#666"
>
{item.year}
</text>
<text
x={20 + index * 60}
y={item.y - 5}
textAnchor="middle"
fontSize="9"
fill={chartInfo.color}
>
{item.value}
</text>
</g>
))}
</svg>
</div>
);
};
// 실제 데이터 계산
const bmi = calculateBMI(data.latestRecord?.height, data.latestRecord?.weight);
const bmiStatus = getBMIStatus(bmi);
const bloodPressureStatus = getBloodPressureStatus(data.latestRecord?.systolic_bp, data.latestRecord?.diastolic_bp);
const cholesterolStatus = getCholesterolStatus(data.latestRecord?.total_cholesterol);
const glucoseStatus = getGlucoseStatus(data.latestRecord?.fasting_glucose);
return (
<div className="dashboard-page">
<Header title="건강 대시보드" showBack backPath="/chat" />
<div className="content dashboard-content">
<div className="dashboard-card">
<h3 style={{ marginBottom: '16px' }}>기본 정보</h3>
<div className="metric">
<span>나이/성별</span>
<span>
{data.basicInfo?.age || 32} / {data.basicInfo?.gender_code === 1 ? "남성" : data.basicInfo?.gender_code === 2 ? "여성" : "남성"}
</span>
</div>
<div className="metric">
<span>직업군</span>
<span>{data.basicInfo?.occupation || "IT개발"}</span>
</div>
<div className="metric">
<span>BMI</span>
<span style={{ color: bmiStatus.color }}>{bmi} ({bmiStatus.text})</span>
</div>
</div>
<div className="dashboard-card">
<h3 style={{ marginBottom: '16px' }}>
최근 검진 결과
<span style={{ fontSize: '14px', fontWeight: 'normal', color: '#666', marginLeft: '8px' }}>
({data.latestRecord?.reference_year || 2024})
</span>
</h3>
<div className="metric">
<span>혈압</span>
<span style={{ color: bloodPressureStatus.color }}>
{data.latestRecord?.systolic_bp || 140}/{data.latestRecord?.diastolic_bp || 90} mmHg
</span>
</div>
<div className="metric">
<span>신장/체중</span>
<span>
{data.latestRecord?.height || 175}cm / {data.latestRecord?.weight || 70}kg
</span>
</div>
<div className="metric">
<span>허리둘레</span>
<span>{data.latestRecord?.waist_circumference || 85}cm</span>
</div>
<div className="metric">
<span>시력 (/)</span>
<span>
{(data.latestRecord?.visual_acuity_left || 1.0).toFixed(1)} / {(data.latestRecord?.visual_acuity_right || 1.0).toFixed(1)}
</span>
</div>
<div className="metric">
<span>청력 (/)</span>
<span>
{data.latestRecord?.hearing_left || 25}dB / {data.latestRecord?.hearing_right || 25}dB
</span>
</div>
<div className="metric">
<span>공복혈당</span>
<span style={{ color: glucoseStatus.color }}>
{data.latestRecord?.fasting_glucose || 95} mg/dL
</span>
</div>
<div className="metric">
<span>총콜레스테롤</span>
<span style={{ color: cholesterolStatus.color }}>
{data.latestRecord?.total_cholesterol || 220} mg/dL
</span>
</div>
<div className="metric">
<span>트리글리세라이드</span>
<span>{data.latestRecord?.triglyceride || 150} mg/dL</span>
</div>
<div className="metric">
<span>HDL콜레스테롤</span>
<span>{data.latestRecord?.hdl_cholesterol || 50} mg/dL</span>
</div>
<div className="metric">
<span>LDL콜레스테롤</span>
<span>{data.latestRecord?.ldl_cholesterol || 130} mg/dL</span>
</div>
<div className="metric">
<span>혈색소</span>
<span>{(data.latestRecord?.hemoglobin || 14.5).toFixed(1)} g/dL</span>
</div>
<div className="metric">
<span>요단백</span>
<span>{data.latestRecord?.urine_protein === 1 ? "양성" : "음성"}</span>
</div>
<div className="metric">
<span>혈청크레아티닌</span>
<span>{(data.latestRecord?.serum_creatinine || 1.0).toFixed(1)} mg/dL</span>
</div>
<div className="metric">
<span>AST/GOT</span>
<span>{data.latestRecord?.ast || 25} IU/L</span>
</div>
<div className="metric">
<span>ALT/GPT</span>
<span>{data.latestRecord?.alt || 30} IU/L</span>
</div>
<div className="metric">
<span>감마지티피</span>
<span>{data.latestRecord?.gamma_gtp || 25} IU/L</span>
</div>
<div className="metric">
<span>흡연상태</span>
<span>{data.latestRecord?.smoking_status === 1 ? "흡연" : "비흡연"}</span>
</div>
<div className="metric">
<span>음주여부</span>
<span>{data.latestRecord?.drinking_status === 1 ? "음주" : "금주"}</span>
</div>
</div>
<div className="dashboard-card">
<h3 style={{ marginBottom: '16px' }}>5년간 추이</h3>
<div className="chart-tabs">
<button
className={`chart-tab ${activeChart === 'bloodPressure' ? 'active' : ''}`}
onClick={() => handleChartChange('bloodPressure')}
>
혈압
</button>
<button
className={`chart-tab ${activeChart === 'cholesterol' ? 'active' : ''}`}
onClick={() => handleChartChange('cholesterol')}
>
콜레스테롤
</button>
<button
className={`chart-tab ${activeChart === 'weight' ? 'active' : ''}`}
onClick={() => handleChartChange('weight')}
>
체중
</button>
</div>
{renderChart()}
{/* 추가 여백 */}
<div style={{ height: '40px' }}></div>
</div>
</div>
<BottomNav />
</div>
);
}
export default DashboardPage;

556
src/pages/GoalsPage.js Normal file
View File

@ -0,0 +1,556 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import Header from '../components/Header';
import BottomNav from '../components/BottomNav';
function GoalsPage({ goalData, setGoalData }) {
const navigate = useNavigate();
const [updating, setUpdating] = useState({});
const [missionData, setMissionData] = useState(null);
const [loading, setLoading] = useState(true);
// 안전한 goalData 접근
const safeGoalData = goalData || {};
// goalType을 missionId로 변환하는 함수
const getMissionId = (goalType) => {
const missionIdMap = {
'water': '1101',
'walk': '1750062662886002',
'stretch': '1750062662866001',
'snack': 'mission_snack_001',
'sleep': 'mission_sleep_001'
};
return missionIdMap[goalType] || goalType;
};
// API 미션 데이터를 goalData 형식으로 변환 (API 문서 기준)
const convertMissionDataToGoalData = (dailyMissions) => {
const converted = {};
const usedGoalTypes = new Set(); // 이미 사용된 goalType 추적
dailyMissions.forEach((mission, index) => {
let goalType = 'unknown';
const title = mission.title.toLowerCase();
if (title.includes('물') || title.includes('water')) {
goalType = 'water';
} else if (title.includes('걷기') || title.includes('산책') || title.includes('walk')) {
goalType = 'walk';
} else if (title.includes('스트레칭') || title.includes('stretch')) {
goalType = 'stretch';
} else if (title.includes('간식') || title.includes('snack')) {
goalType = 'snack';
} else if (title.includes('수면') || title.includes('sleep')) {
goalType = 'sleep';
} else {
goalType = 'default';
}
// 중복 체크: 이미 사용된 goalType이면 고유한 키 생성
if (usedGoalTypes.has(goalType)) {
goalType = `${goalType}_${mission.missionId}`;
console.log(`### 중복 발견! 새로운 goalType: ${goalType}`);
}
usedGoalTypes.add(goalType);
console.log(`### 최종 goalType: ${goalType} for mission: ${mission.title}`);
// API 문서 형식에 맞춘 필드 매핑
converted[goalType] = {
missionId: mission.missionId,
current: mission.completedCount || 0,
target: mission.targetCount || 1,
goalName: mission.title,
description: mission.description,
status: mission.status,
completedToday: mission.completedToday,
streakDays: mission.streakDays,
completionRate: mission.completionRate,
targetAchieved: mission.targetAchieved,
nextReminderTime: mission.nextReminderTime
};
});
return converted;
};
// localStorage에 일일 성과 저장 (모든 미션 포함, 정확한 달성률)
const saveDailyProgressToLocal = (currentGoalData = null) => {
try {
const dataToSave = currentGoalData || goalData;
if (!dataToSave || Object.keys(dataToSave).length === 0) {
console.log('⚠️ goalData가 없어서 로컬 저장 건너뛰기');
return;
}
const today = new Date().toISOString().split('T')[0];
const goals = Object.values(dataToSave);
const total = goals.length;
const completed = goals.filter(goal => goal && goal.current >= goal.target).length;
const rate = Math.round((completed / total) * 100);
const dailyData = {
date: today,
progress: { rate, completed, total },
goals: Object.keys(dataToSave).map(goalType => {
const goal = dataToSave[goalType];
const current = goal.current || 0;
const target = goal.target || 1;
const isCompleted = current >= target;
// 정확한 달성률 계산: 완료했으면 100%, 아니면 current/target 비율
const accurateRate = isCompleted ? 100 : Math.round((current / target) * 100);
console.log(`📊 ${goal.goalName} 달성률 계산:`, {
현재: current,
목표: target,
완료여부: isCompleted,
달성률: accurateRate + '%'
});
return {
type: goalType,
name: goal.goalName || goalType,
missionId: goal.missionId,
current: current,
target: target,
completed: isCompleted,
rate: accurateRate, // 정확한 달성률 (0%, 50%, 100% 등)
needsSync: goal.needsSync || false
};
}),
timestamp: new Date().toISOString(),
allMissionsIncluded: true // 중요: 모든 미션이 포함되었음을 표시
};
const existingData = JSON.parse(localStorage.getItem('dailyMissionHistory') || '[]');
const updatedData = existingData.filter(item =>
new Date(item.date) >= new Date(Date.now() - 30 * 24 * 60 * 60 * 1000)
);
const todayIndex = updatedData.findIndex(item => item.date === today);
if (todayIndex >= 0) {
updatedData[todayIndex] = dailyData;
} else {
updatedData.push(dailyData);
}
localStorage.setItem('dailyMissionHistory', JSON.stringify(updatedData));
console.log('💾 일일 진행률 로컬 저장 완료 (정확한 달성률 포함):', dailyData);
} catch (error) {
console.warn('⚠️ 로컬 저장 실패:', error);
}
};
// 미션 진행 API 호출 (수정된 데이터 형식)
const recordMissionProgress = async (goalType, currentValue) => {
try {
const token = localStorage.getItem('authToken');
const memberSerialNumber = localStorage.getItem('memberSerialNumber') ||
localStorage.getItem('userId') || '1';
const goalInfo = safeGoalData[goalType];
const missionId = goalInfo?.missionId || getMissionId(goalType);
const isCompleted = currentValue >= (goalInfo?.target || 1);
// API 문서에 맞는 정확한 데이터 구조
const requestData = {
memberSerialNumber: parseInt(memberSerialNumber),
completed: isCompleted,
completedAt: new Date().toISOString(),
notes: `${goalInfo?.goalName || goalType} 목표 진행`,
incrementCount: 1,
completionType: "INCREMENT",
incrementMode: true,
fullCompleteMode: isCompleted
// updateHistory, currentProgress, targetProgress, achievementRate 제거
// - API 문서에 없는 필드들
};
console.log(`🚀 ${goalType} 미션 완료 API 요청:`, requestData);
const response = await fetch(`${window.__runtime_config__?.GOAL_URL}/missions/${missionId}/complete`, {
method: 'PUT',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(requestData)
});
if (!response.ok) {
const errorText = await response.text();
console.error(`${goalType} 미션 완료 API 오류 (${response.status}):`, errorText);
// 상세 에러 로그
if (response.status === 400) {
console.error('🔍 Bad Request - 요청 데이터:', requestData);
} else if (response.status === 404) {
console.error('🔍 Not Found - 미션 ID가 존재하지 않음:', missionId);
} else if (response.status === 500) {
console.error('🔍 Internal Server Error - 서버 내부 오류');
}
throw new Error(`미션 완료 기록 실패 (${response.status}): ${errorText}`);
}
const result = await response.json();
console.log(`${goalType} 미션 완료 API 성공:`, result);
return result;
} catch (error) {
console.error(`💥 ${goalType} 미션 완료 API 호출 실패:`, error);
throw error;
}
};
// 완료 메시지 표시
const showCompletionMessage = (goalType, apiResponse = null) => {
const goalInfo = safeGoalData[goalType];
const goalName = goalInfo?.goalName || goalType;
let message = '';
if (apiResponse && apiResponse.data && apiResponse.data.achievementMessage) {
message = apiResponse.data.achievementMessage;
}
if (!message) {
message = `🎉 ${goalName} 목표 달성! 잘하셨어요!`;
}
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
background: #27ae60; color: white; padding: 16px 24px; border-radius: 8px;
z-index: 1000; font-weight: bold; text-align: center;
box-shadow: 0 4px 12px rgba(0,0,0,0.3); max-width: 300px; word-wrap: break-word;
`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
if (document.body.contains(notification)) {
document.body.removeChild(notification);
}
}, 3000);
};
// 목표 업데이트 (간소화된 에러 처리)
const updateGoal = async (goalType, increment = 1) => {
const currentGoal = safeGoalData[goalType] || { current: 0, target: 1 };
if (updating[goalType] || currentGoal.current >= currentGoal.target) {
return;
}
const newCurrent = Math.min(currentGoal.current + increment, currentGoal.target);
setUpdating(prev => ({ ...prev, [goalType]: true }));
// 먼저 로컬 상태 업데이트 (즉각적인 UI 반응)
const updatedGoalData = {
...goalData,
[goalType]: {
...goalData[goalType],
current: newCurrent,
completedToday: newCurrent >= currentGoal.target
}
};
setGoalData(updatedGoalData);
if (newCurrent >= currentGoal.target) {
showCompletionMessage(goalType);
}
try {
// 서버 API 호출 시도
const apiResponse = await recordMissionProgress(goalType, newCurrent);
console.log(`${goalType} 서버 동기화 성공:`, apiResponse);
// 성공 시 완료 메시지 업데이트
if (newCurrent >= currentGoal.target && apiResponse.data?.achievementMessage) {
showCompletionMessage(goalType, apiResponse);
}
} catch (error) {
console.warn(`⚠️ ${goalType} 서버 동기화 실패 (로컬은 정상 업데이트됨):`, error.message);
// 동기화 필요 표시
const failedSyncGoalData = {
...updatedGoalData,
[goalType]: {
...updatedGoalData[goalType],
needsSync: true
}
};
setGoalData(failedSyncGoalData);
}
// 항상 로컬 저장 수행
setTimeout(() => {
saveDailyProgressToLocal(updatedGoalData);
}, 300);
setUpdating(prev => ({ ...prev, [goalType]: false }));
};
// 컴포넌트 마운트 시 한 번만 실행 (의도적으로 의존성 제외)
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(() => {
const loadActiveMissions = async () => {
try {
setLoading(true);
const token = localStorage.getItem('authToken');
const memberSerialNumber = localStorage.getItem('memberSerialNumber') ||
localStorage.getItem('userId') || '1';
const params = new URLSearchParams({
memberSerialNumber: memberSerialNumber
});
const response = await fetch(`${window.__runtime_config__?.GOAL_URL}/missions/active?${params}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
console.log('활성 미션 조회 성공:', result);
if (result.success && result.data) {
setMissionData(result.data);
const convertedGoalData = convertMissionDataToGoalData(result.data.dailyMissions);
setGoalData(convertedGoalData);
// 초기 데이터 처리 (간소화)
setTimeout(() => {
if (convertedGoalData && Object.keys(convertedGoalData).length > 0) {
console.log('📱 초기 데이터 로컬 저장');
// 로컬 저장만 수행 (서버 동기화는 사용자 액션 시에만)
saveDailyProgressToLocal(convertedGoalData);
}
}, 1000);
}
} catch (error) {
console.error('활성 미션 조회 오류:', error);
} finally {
setLoading(false);
}
};
loadActiveMissions();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 빈 배열로 한 번만 실행
const calculateProgress = () => {
if (!goalData || Object.keys(goalData).length === 0) return 0;
const totalGoals = Object.keys(goalData).length;
const completedGoals = Object.values(goalData).filter(
goal => goal && goal.current >= goal.target
).length;
return Math.round((completedGoals / totalGoals) * 100);
};
const getCompletedCount = () => {
if (!goalData || Object.keys(goalData).length === 0) return 0;
return Object.values(goalData).filter(
goal => goal && goal.current >= goal.target
).length;
};
const generateGoalsFromData = () => {
const defaultGoals = [
{ id: 'water', icon: '💧', title: '하루 8잔 물마시기', unit: '잔' },
{ id: 'walk', icon: '🚶‍♂️', title: '점심시간 10분 산책', unit: '회' },
{ id: 'stretch', icon: '🧘‍♀️', title: '5분 목&어깨 스트레칭', unit: '회' },
{ id: 'snack', icon: '🥗', title: '하루 1회 건강간식', unit: '회' },
{ id: 'sleep', icon: '😴', title: '규칙적인 수면시간', unit: '회' }
];
if (Object.keys(safeGoalData).length > 0) {
return Object.keys(safeGoalData).map(goalType => {
const goalInfo = safeGoalData[goalType];
// 기본 타입과 확장 타입 구분
const baseType = goalType.split('_')[0]; // 'stretch_1234' → 'stretch'
const defaultGoal = defaultGoals.find(g => g.id === baseType);
return {
id: goalType,
icon: defaultGoal?.icon || '🎯',
title: goalInfo.goalName || defaultGoal?.title || goalType,
unit: defaultGoal?.unit || '회'
};
});
}
return defaultGoals;
};
const goals = generateGoalsFromData();
const progress = calculateProgress();
const completedCount = getCompletedCount();
const totalGoals = goals.length;
if (loading) {
return (
<div className="goals-page">
<Header title="목표 관리" showBack backPath="/chat" />
<div className="content" style={{ paddingBottom: '80px' }}>
<div style={{ textAlign: 'center', padding: '40px' }}>
<p>목표를 불러오는 ...</p>
</div>
</div>
<BottomNav />
</div>
);
}
return (
<div className="goals-page">
<Header title="목표 관리" showBack backPath="/chat" />
<div className="content" style={{ paddingBottom: '80px' }}>
<div className="dashboard-card">
<div className="section-header">
<h3>오늘의 미션</h3>
<button
className="history-btn"
onClick={() => navigate('/history')}
>
이력
</button>
</div>
<div className="progress-bar">
<div className="progress-fill" style={{ width: `${progress}%` }}></div>
</div>
<p style={{ textAlign: 'center', margin: '8px 0', color: '#666', fontSize: '14px' }}>
{completedCount}/{totalGoals} 완료 ({progress}%)
</p>
{missionData?.motivationalMessage && (
<div style={{
marginTop: '12px',
padding: '8px 12px',
backgroundColor: '#f0f8f0',
borderRadius: '6px',
textAlign: 'center'
}}>
<p style={{ margin: 0, fontSize: '14px', color: '#27ae60' }}>
{missionData.motivationalMessage}
</p>
</div>
)}
{missionData?.currentStreak > 0 && (
<div style={{
marginTop: '8px',
textAlign: 'center',
fontSize: '13px',
color: '#667eea'
}}>
🔥 연속 {missionData.currentStreak} 달성 !
{missionData.bestStreak && (
<span style={{ color: '#666', marginLeft: '8px' }}>
(최고 기록: {missionData.bestStreak})
</span>
)}
</div>
)}
</div>
{goals.map(goal => {
const data = safeGoalData[goal.id] || { current: 0, target: 1 };
const isCompleted = data.current >= data.target;
const isUpdating = updating[goal.id];
return (
<div key={goal.id} className="todo-item">
<div className="todo-text">
<div style={{ display: 'flex', alignItems: 'center' }}>
<span style={{ marginRight: '8px' }}>{goal.icon}</span>
<div>
<div>{goal.title}</div>
{data.description && (
<div style={{ fontSize: '12px', color: '#666', marginTop: '2px' }}>
{data.description.length > 50 ?
`${data.description.substring(0, 50)}...` :
data.description
}
</div>
)}
{data.streakDays > 0 && (
<div style={{ fontSize: '11px', color: '#f39c12', marginTop: '2px' }}>
🔥 {data.streakDays} 연속
</div>
)}
{data.needsSync && (
<div style={{ fontSize: '10px', color: '#e74c3c', marginTop: '2px' }}>
📡 동기화 대기
</div>
)}
</div>
</div>
</div>
<div className="goal-counter">
<span className="goal-count">
{data.current}/{data.target}{goal.unit}
</span>
<button
className={`goal-btn ${isCompleted ? 'completed' : ''} ${isUpdating ? 'updating' : ''}`}
onClick={() => updateGoal(goal.id)}
disabled={isCompleted || isUpdating}
>
{isUpdating ? '...' : isCompleted ? '✓' : '+'}
</button>
</div>
</div>
);
})}
{completedCount === totalGoals && totalGoals > 0 && (
<div className="achievement-banner">
<h3>🎉 오늘의 모든 미션 완료!</h3>
<p>정말 대단해요! 건강한 하루를 보내셨네요!</p>
{missionData?.completionRate === 100 && (
<p style={{ fontSize: '14px', color: '#27ae60', marginTop: '8px' }}>
달성률 100%! 완벽한 하루였습니다! 👏
</p>
)}
</div>
)}
{goals.length === 0 && (
<div className="dashboard-card">
<div style={{ textAlign: 'center', padding: '20px' }}>
<p style={{ color: '#666' }}>설정된 미션이 없습니다.</p>
<button
className="btn btn-primary"
onClick={() => navigate('/mission')}
style={{ marginTop: '16px' }}
>
미션 설정하기
</button>
</div>
</div>
)}
</div>
<BottomNav />
</div>
);
}
export default GoalsPage;

630
src/pages/HistoryPage.js Normal file
View File

@ -0,0 +1,630 @@
import React, { useState, useEffect, useCallback } from 'react';
import Header from '../components/Header';
function HistoryPage({ goalData }) { // ✅ goalData props 추가
const [selectedPeriod, setSelectedPeriod] = useState('week');
const [historyData, setHistoryData] = useState(null);
const [loading, setLoading] = useState(false);
// 미션 아이콘 헬퍼 함수
const getMissionIcon = useCallback((title) => {
if (!title) return '📝';
const titleLower = title.toLowerCase();
if (titleLower.includes('물') || titleLower.includes('water')) return '💧';
if (titleLower.includes('걷기') || titleLower.includes('산책') || titleLower.includes('walk')) return '🚶‍♂️';
if (titleLower.includes('스트레칭') || titleLower.includes('stretch')) return '🧘‍♀️';
if (titleLower.includes('간식') || titleLower.includes('snack') || titleLower.includes('식사')) return '🥗';
if (titleLower.includes('수면') || titleLower.includes('sleep')) return '😴';
if (titleLower.includes('운동') || titleLower.includes('exercise')) return '💪';
if (titleLower.includes('명상') || titleLower.includes('meditation')) return '🧘';
return '📝';
}, []);
// 진행률 색상 헬퍼 함수
const getProgressColor = useCallback((rate) => {
if (rate >= 80) return '#27ae60';
if (rate >= 60) return '#f39c12';
return '#e74c3c';
}, []);
// 상태 메시지 헬퍼 함수
const getStatusMessage = useCallback((rate) => {
if (rate >= 80) return '우수';
if (rate >= 60) return '보통';
return '개선필요';
}, []);
// ✅ 현재 GoalsPage 데이터를 기반으로 즉시 달성률 계산
const getCurrentAchievementData = useCallback(() => {
console.log('🎯 현재 목표 데이터 기반 달성률 계산:', goalData);
if (!goalData || Object.keys(goalData).length === 0) {
console.log('⚠️ goalData 없음');
return null;
}
const goals = Object.keys(goalData);
const totalGoals = goals.length;
const completedGoals = goals.filter(goalType => {
const goal = goalData[goalType];
return goal && goal.current >= goal.target;
}).length;
const overallRate = Math.round((completedGoals / totalGoals) * 100);
// goalStats 생성
const goalStats = goals.map(goalType => {
const goal = goalData[goalType];
const current = goal?.current || 0;
const target = goal?.target || 1;
const rate = Math.round((current / target) * 100);
return {
goalName: goal?.goalName || goalType,
icon: getMissionIcon(goal?.goalName || goalType),
rate: Math.min(rate, 100), // 100% 초과 방지
completed: current >= target ? 1 : 0,
total: 1,
current,
target
};
});
console.log('📊 계산된 즉시 달성률:', {
overallRate,
completedGoals,
totalGoals,
goalStats
});
return {
overallRate,
goalStats,
totalMissions: totalGoals,
averageCompletionRate: overallRate,
bestPerformingMission: goalStats.length > 0 ?
[...goalStats].sort((a, b) => b.rate - a.rate)[0].goalName : null,
improvementNeeded: goalStats.find(g => g.rate < 60)?.goalName || null,
bestStreak: 0,
insights: [],
chartData: null
};
}, [goalData, getMissionIcon]);
// 기간에 따른 날짜 범위 계산
const getDateRange = useCallback((period) => {
const endDate = new Date();
const startDate = new Date();
if (period === 'week') {
startDate.setDate(endDate.getDate() - 7);
} else {
startDate.setDate(endDate.getDate() - 30);
}
return {
startDate: startDate.toISOString().split('T')[0],
endDate: endDate.toISOString().split('T')[0]
};
}, []);
// 서버 데이터를 UI 형태로 변환
const transformServerData = useCallback((serverData) => {
console.log('🔄 서버 데이터 변환:', serverData);
const goalStats = serverData.missionStats?.map(mission => ({
goalName: mission.title,
icon: getMissionIcon(mission.title),
rate: Math.round(mission.achievementRate || 0),
completed: mission.completedDays || 0,
total: mission.totalDays || 0
})) || [];
// 최고 성과와 개선 필요 항목 찾기
let bestPerformingMission = null;
let improvementNeeded = null;
if (goalStats.length > 0) {
const sortedByRate = [...goalStats].sort((a, b) => b.rate - a.rate);
bestPerformingMission = sortedByRate[0].goalName;
const worstMission = sortedByRate[sortedByRate.length - 1];
if (worstMission.rate < 60) {
improvementNeeded = worstMission.goalName;
}
}
return {
overallRate: Math.round(serverData.totalAchievementRate || 0),
period: selectedPeriod === 'week' ? '지난 일주일' : '지난 한달',
goalStats,
totalMissions: goalStats.length,
averageCompletionRate: Math.round(serverData.periodAchievementRate || 0),
bestPerformingMission,
improvementNeeded,
bestStreak: serverData.bestStreak || 0,
insights: serverData.insights || [],
chartData: serverData.chartData
};
}, [selectedPeriod, getMissionIcon]);
// 기본 빈 데이터 구조 (개선됨)
const getDefaultHistoryData = useCallback(() => {
console.log('📋 기본 데이터 생성 시도');
// ✅ goalData가 있으면 현재 상태 기반으로 생성
const currentData = getCurrentAchievementData();
if (currentData) {
console.log('✅ 현재 목표 데이터로 기본값 생성');
return {
...currentData,
period: selectedPeriod === 'week' ? '지난 일주일 (현재 상태)' : '지난 한달 (현재 상태)'
};
}
// goalData도 없으면 완전 빈 상태
return {
overallRate: 0,
period: selectedPeriod === 'week' ? '지난 일주일' : '지난 한달',
goalStats: [],
totalMissions: 0,
averageCompletionRate: 0,
bestPerformingMission: null,
improvementNeeded: null,
bestStreak: 0,
insights: [],
chartData: null
};
}, [selectedPeriod, getCurrentAchievementData]);
// 로컬 데이터에서 이력 정보 가져오기 (개선됨)
const getLocalHistoryData = useCallback(() => {
try {
const localData = JSON.parse(localStorage.getItem('dailyMissionHistory') || '[]');
console.log('📱 로컬 저장된 이력 데이터:', localData);
if (!localData.length) {
console.log('📊 로컬 데이터 없음 - 현재 목표 데이터 사용 시도');
return getCurrentAchievementData(); // ✅ 현재 데이터로 대체
}
// 선택된 기간에 따라 데이터 필터링
const endDate = new Date();
const startDate = new Date();
if (selectedPeriod === 'week') {
startDate.setDate(endDate.getDate() - 7);
} else {
startDate.setDate(endDate.getDate() - 30);
}
const filteredData = localData.filter(item => {
const itemDate = new Date(item.date);
return itemDate >= startDate && itemDate <= endDate;
});
console.log(`📅 ${selectedPeriod} 기간 필터링 결과:`, {
전체데이터: localData.length,
필터링후: filteredData.length
});
if (!filteredData.length) {
console.log('📊 필터링된 데이터 없음 - 현재 목표 데이터 사용');
return getCurrentAchievementData(); // ✅ 현재 데이터로 대체
}
// 미션별 통계 계산
const missionStats = {};
filteredData.forEach(day => {
day.goals.forEach(goal => {
if (!missionStats[goal.type]) {
missionStats[goal.type] = {
name: goal.name,
totalDays: 0,
completedDays: 0,
missionId: goal.missionId,
icon: getMissionIcon(goal.name)
};
}
missionStats[goal.type].totalDays++;
if (goal.completed) {
missionStats[goal.type].completedDays++;
}
});
});
// 전체 달성률 계산
const totalPossibleMissions = filteredData.reduce((sum, day) => sum + day.goals.length, 0);
const totalCompletedMissions = filteredData.reduce((sum, day) =>
sum + day.goals.filter(goal => goal.completed).length, 0
);
const overallRate = totalPossibleMissions > 0 ?
Math.round((totalCompletedMissions / totalPossibleMissions) * 100) : 0;
// goalStats 형식으로 변환
const goalStats = Object.values(missionStats).map(stat => ({
goalName: stat.name,
icon: stat.icon,
rate: stat.totalDays > 0 ? Math.round((stat.completedDays / stat.totalDays) * 100) : 0,
completed: stat.completedDays,
total: stat.totalDays
}));
return {
overallRate,
goalStats,
totalMissions: goalStats.length,
averageCompletionRate: overallRate
};
} catch (error) {
console.error('로컬 데이터 처리 오류:', error);
return getCurrentAchievementData(); // ✅ 오류 시에도 현재 데이터 사용
}
}, [selectedPeriod, getMissionIcon, getCurrentAchievementData]);
// 메인 데이터 로딩 함수 (개선됨)
const loadHistoryData = useCallback(async () => {
setLoading(true);
try {
console.log('🔄 이력 데이터 로딩 시작');
// ✅ 먼저 로컬 데이터 확인 (현재 목표 데이터 포함)
const localData = getLocalHistoryData();
if (localData && (localData.goalStats.length > 0 || localData.overallRate > 0)) {
console.log('✅ 로컬/현재 데이터 사용:', localData);
const transformedLocalData = {
...localData,
period: selectedPeriod === 'week' ? '지난 일주일' : '지난 한달',
bestPerformingMission: localData.goalStats.length > 0 ?
[...localData.goalStats].sort((a, b) => b.rate - a.rate)[0].goalName : null,
improvementNeeded: localData.goalStats.find(g => g.rate < 60)?.goalName || null,
bestStreak: 0,
insights: [],
chartData: null
};
setHistoryData(transformedLocalData);
setLoading(false);
return; // 데이터가 있으면 서버 호출하지 않음
}
// 로컬/현재 데이터도 없으면 서버에서 조회
console.log('🌐 서버 데이터 조회');
const token = localStorage.getItem('authToken');
const memberSerialNumber = localStorage.getItem('memberSerialNumber') ||
localStorage.getItem('userId') || '1';
const { startDate, endDate } = getDateRange(selectedPeriod);
const params = new URLSearchParams({
memberSerialNumber: memberSerialNumber,
startDate: startDate,
endDate: endDate
});
const response = await fetch(`${window.__runtime_config__?.GOAL_URL}/missions/history?${params}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
console.log('📡 이력 API 응답 상태:', response.status, response.statusText);
if (!response.ok) {
if (response.status === 404) {
console.log('서버에 이력 데이터 없음 - 기본 데이터 표시');
setHistoryData(getDefaultHistoryData());
return;
}
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
console.log('✅ 서버 이력 데이터 로드 성공:', result);
if (result.success && result.data) {
const transformedData = transformServerData(result.data);
setHistoryData(transformedData);
} else {
console.log('서버 응답에 데이터 없음 - 기본 데이터 표시');
setHistoryData(getDefaultHistoryData());
}
} catch (error) {
console.error('❌ 목표 이력 로딩 오류:', error);
setHistoryData(getDefaultHistoryData());
} finally {
setLoading(false);
}
}, [selectedPeriod, getDateRange, transformServerData, getDefaultHistoryData, getLocalHistoryData]);
// 기간 변경 핸들러
const handlePeriodChange = useCallback((period) => {
console.log(`🔄 기간 변경: ${selectedPeriod}${period}`);
setSelectedPeriod(period);
}, [selectedPeriod]);
// ✅ goalData 변경 감지 및 즉시 반영
useEffect(() => {
console.log('🔄 goalData 변경 감지:', goalData);
if (goalData && Object.keys(goalData).length > 0) {
// 현재 데이터를 기반으로 즉시 업데이트
const currentData = getCurrentAchievementData();
if (currentData) {
console.log('⚡ goalData 기반 즉시 업데이트');
setHistoryData({
...currentData,
period: selectedPeriod === 'week' ? '지난 일주일 (현재 상태)' : '지난 한달 (현재 상태)'
});
}
}
}, [goalData, getCurrentAchievementData, selectedPeriod]);
// useEffect
useEffect(() => {
loadHistoryData();
}, [loadHistoryData]);
// 로딩 중 렌더링
if (loading) {
return (
<div className="history-page">
<Header title="목표 달성 이력" showBack backPath="/goals" />
<div className="content">
<div style={{ textAlign: 'center', padding: '40px' }}>
<div style={{ marginBottom: '16px' }}>📊</div>
<p>이력을 불러오는 ...</p>
</div>
</div>
</div>
);
}
// 메인 렌더링
return (
<div className="history-page">
<Header title="목표 달성 이력" showBack backPath="/goals" />
<div className="content">
{/* 기간 선택 */}
<div className="dashboard-card">
<h3 style={{ marginBottom: '16px' }}>기간 선택</h3>
<div className="period-buttons">
<button
className={`period-btn ${selectedPeriod === 'week' ? 'active' : ''}`}
onClick={() => handlePeriodChange('week')}
>
일주일
</button>
<button
className={`period-btn ${selectedPeriod === 'month' ? 'active' : ''}`}
onClick={() => handlePeriodChange('month')}
>
한달
</button>
</div>
</div>
{historyData && (
<>
{/* 전체 달성률 */}
<div className="dashboard-card">
<h3 style={{ marginBottom: '16px' }}>전체 달성률</h3>
<div style={{ textAlign: 'center' }}>
<div style={{
fontSize: '48px',
fontWeight: 'bold',
color: getProgressColor(historyData.overallRate),
marginBottom: '8px'
}}>
{historyData.overallRate}%
</div>
<p style={{ color: '#666', fontSize: '14px', marginBottom: '8px' }}>
{historyData.period} 평균
</p>
<div style={{
display: 'inline-block',
padding: '4px 12px',
backgroundColor: getProgressColor(historyData.overallRate),
color: 'white',
borderRadius: '16px',
fontSize: '12px',
fontWeight: 'bold'
}}>
{getStatusMessage(historyData.overallRate)}
</div>
{historyData.bestStreak > 0 && (
<p style={{ color: '#666', fontSize: '12px', marginTop: '8px' }}>
🔥 최고 연속: {historyData.bestStreak}
</p>
)}
</div>
</div>
{/* 항목별 달성률 */}
<div className="dashboard-card">
<h3 style={{ marginBottom: '16px' }}>항목별 달성률</h3>
{historyData.goalStats && historyData.goalStats.length > 0 ? (
historyData.goalStats.map((goal, index) => (
<div key={index} style={{ marginBottom: '20px' }}>
<div style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '8px'
}}>
<div style={{ display: 'flex', alignItems: 'center' }}>
<span style={{ marginRight: '8px', fontSize: '18px' }}>{goal.icon}</span>
<span style={{ fontWeight: '500' }}>{goal.goalName}</span>
{goal.current !== undefined && goal.target !== undefined && (
<span style={{
marginLeft: '8px',
fontSize: '12px',
color: '#666'
}}>
({goal.current}/{goal.target})
</span>
)}
</div>
<div style={{ textAlign: 'right' }}>
<span style={{
color: getProgressColor(goal.rate),
fontWeight: 'bold',
fontSize: '16px'
}}>
{goal.rate}%
</span>
<div style={{
fontSize: '11px',
color: getProgressColor(goal.rate),
fontWeight: 'bold',
marginTop: '2px'
}}>
{getStatusMessage(goal.rate)}
</div>
</div>
</div>
<div className="progress-bar">
<div
className="progress-fill"
style={{
width: `${goal.rate}%`,
backgroundColor: getProgressColor(goal.rate)
}}
></div>
</div>
<p style={{
fontSize: '12px',
color: '#666',
textAlign: 'right',
marginTop: '4px'
}}>
{goal.completed}/{goal.total} 달성
</p>
</div>
))
) : (
<div style={{ textAlign: 'center', padding: '20px', color: '#666' }}>
<p>아직 달성 이력 데이터가 없습니다.</p>
<p style={{ fontSize: '14px', marginTop: '8px' }}>
미션을 수행하면 여기에서 달성률을 확인할 있어요!
</p>
</div>
)}
</div>
{/* 달성 통계 요약 */}
<div className="dashboard-card">
<h3 style={{ marginBottom: '16px' }}>달성 요약</h3>
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr 1fr',
gap: '12px',
textAlign: 'center'
}}>
<div>
<div style={{ fontSize: '20px', fontWeight: 'bold', color: '#27ae60' }}>
{historyData.goalStats ? historyData.goalStats.filter(goal => goal.rate >= 80).length : 0}
</div>
<p style={{ fontSize: '11px', color: '#666', marginTop: '4px' }}>우수 달성</p>
</div>
<div>
<div style={{ fontSize: '20px', fontWeight: 'bold', color: '#f39c12' }}>
{historyData.goalStats ? historyData.goalStats.filter(goal => goal.rate >= 60 && goal.rate < 80).length : 0}
</div>
<p style={{ fontSize: '11px', color: '#666', marginTop: '4px' }}>보통 달성</p>
</div>
<div>
<div style={{ fontSize: '20px', fontWeight: 'bold', color: '#e74c3c' }}>
{historyData.goalStats ? historyData.goalStats.filter(goal => goal.rate < 60).length : 0}
</div>
<p style={{ fontSize: '11px', color: '#666', marginTop: '4px' }}>개선 필요</p>
</div>
</div>
</div>
{/* AI 인사이트 */}
{historyData.insights && historyData.insights.length > 0 && (
<div className="dashboard-card">
<h3 style={{ marginBottom: '16px' }}>🤖 AI 인사이트</h3>
{historyData.insights.map((insight, index) => (
<div key={index} style={{
padding: '12px',
backgroundColor: '#f8f9fa',
borderLeft: '4px solid #007bff',
borderRadius: '4px',
marginBottom: '8px'
}}>
<p style={{ margin: 0, fontSize: '14px', color: '#495057' }}>
{insight}
</p>
</div>
))}
</div>
)}
{/* 개선 제안 */}
{historyData.improvementNeeded && (
<div className="dashboard-card">
<h3 style={{ marginBottom: '16px' }}>💡 개선 제안</h3>
<div style={{
padding: '12px',
backgroundColor: '#fff3cd',
borderLeft: '4px solid #ffc107',
borderRadius: '4px'
}}>
<p style={{ margin: 0, fontSize: '14px', color: '#856404' }}>
<strong>{historyData.improvementNeeded}</strong> 항목의 달성률이 낮습니다.<br />
조금 신경써서 실천해보세요! 💪
</p>
</div>
</div>
)}
{/* 최고 성과 */}
{historyData.bestPerformingMission && (
<div className="dashboard-card">
<h3 style={{ marginBottom: '16px' }}>🏆 최고 성과</h3>
<div style={{
padding: '12px',
backgroundColor: '#d4edda',
borderLeft: '4px solid #28a745',
borderRadius: '4px'
}}>
<p style={{ margin: 0, fontSize: '14px', color: '#155724' }}>
<strong>{historyData.bestPerformingMission}</strong> 항목에서 가장 좋은 성과를 보였습니다!<br />
패턴을 다른 목표에도 적용해보세요! 🎉
</p>
</div>
</div>
)}
</>
)}
{/* 데이터가 없는 경우 */}
{!historyData && !loading && (
<div className="dashboard-card">
<div style={{ textAlign: 'center', padding: '40px' }}>
<div style={{ fontSize: '48px', marginBottom: '16px' }}>📊</div>
<p style={{ color: '#666', marginBottom: '16px' }}>
아직 달성 이력이 없습니다.
</p>
<p style={{ color: '#999', fontSize: '14px' }}>
목표를 설정하고 실천하면<br />
이곳에서 달성 현황을 확인할 있어요!
</p>
</div>
</div>
)}
</div>
</div>
);
}
export default HistoryPage;

418
src/pages/LoadingPage.js Normal file
View File

@ -0,0 +1,418 @@
//* src/pages/LoadingPage.js
import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Header from '../components/Header';
import LoadingSpinner from '../components/LoadingSpinner';
function LoadingPage({ setHealthData, setGoalData, setAiMissions, setHealthDiagnosis, user }) {
const navigate = useNavigate();
const [loadingMessage, setLoadingMessage] = useState('건강검진 기록을 확인하고 있습니다');
const isNewUser = localStorage.getItem('isNewUser') === 'true';
useEffect(() => {
loadHealthData();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// 가라 진단 데이터 반환 함수
const getDefaultDiagnosis = () => {
return [
"혈압과 콜레스테롤 수치가 주의 범위에 있어 적극적인 관리가 필요해요!",
"IT 직업 특성상 장시간 앉아있는 생활 패턴으로 인한 건강 리스크가 보여요!",
"하지만 지금 시작하면 충분히 개선 가능하니 함께 건강해져요!"
];
};
// 가라 미션 데이터 반환 함수
const getDefaultMissions = () => {
return [
{
missionId: "mission_001",
title: "매일 물 8잔 마시기",
description: "하루 8잔(약 2L)의 물을 나누어 마셔 수분 균형을 유지하세요.",
category: "nutrition",
difficulty: "easy",
healthBenefit: "신진대사 개선 및 독소 배출",
occupationRelevance: "장시간 앉아 있는 직업군에게 필수적인 수분 공급",
estimatedTimeMinutes: 5
},
{
missionId: "mission_002",
title: "하루 1만보 걷기",
description: "매일 1만보를 목표로 걷기 운동을 실시하세요.",
category: "exercise",
difficulty: "medium",
healthBenefit: "심혈관 건강 개선 및 체중 관리",
occupationRelevance: "사무직 근무자의 운동 부족 해소",
estimatedTimeMinutes: 60
},
{
missionId: "mission_003",
title: "목과 어깨 스트레칭",
description: "1시간마다 5분씩 목과 어깨 스트레칭을 실시하세요.",
category: "exercise",
difficulty: "easy",
healthBenefit: "목과 어깨 근육 긴장 완화",
occupationRelevance: "컴퓨터 업무로 인한 거북목 예방",
estimatedTimeMinutes: 5
},
{
missionId: "mission_004",
title: "규칙적인 수면 패턴",
description: "매일 같은 시간에 잠들고 7-8시간 수면을 유지하세요.",
category: "sleep",
difficulty: "medium",
healthBenefit: "면역력 강화 및 스트레스 감소",
occupationRelevance: "야근이 잦은 직업군의 수면 리듬 정상화",
estimatedTimeMinutes: 480
},
{
missionId: "mission_005",
title: "명상 및 깊은 호흡",
description: "하루 10분간 명상이나 깊은 호흡 운동을 실시하세요.",
category: "mental_health",
difficulty: "easy",
healthBenefit: "스트레스 감소 및 집중력 향상",
occupationRelevance: "업무 스트레스가 높은 환경에서 멘탈 케어",
estimatedTimeMinutes: 10
}
];
};
const loadHealthData = async () => {
try {
// 1단계: 건강검진 기록 확인
setLoadingMessage('건강검진 기록을 확인하고 있습니다');
await new Promise(resolve => setTimeout(resolve, 1500));
// 2단계: 건강검진 데이터 동기화 API 호출
const healthData = await syncHealthCheckupData();
// 건강 데이터를 App.js에 저장
setHealthData(healthData);
// 3단계: 건강진단 3줄 요약 생성
setLoadingMessage('AI가 건강 데이터를 분석하고 있습니다');
await new Promise(resolve => setTimeout(resolve, 1000));
const healthDiagnosis = await generateHealthDiagnosis(healthData, user);
setHealthDiagnosis(healthDiagnosis); // 건강진단 결과를 App.js에 저장
if (!isNewUser) {
// 4-A. 기존 회원: 목표 데이터 로드
setLoadingMessage('설정된 목표를 불러오고 있습니다');
await new Promise(resolve => setTimeout(resolve, 1000));
const goalData = await loadActiveGoals();
setGoalData(goalData);
navigate('/dashboard');
} else {
// 4-B. 신규 회원: AI 미션 추천 생성
setLoadingMessage('AI가 맞춤형 건강 미션을 생성하고 있습니다');
await new Promise(resolve => setTimeout(resolve, 1000));
const aiMissions = await generateAiMissions(healthData, user);
setAiMissions(aiMissions); // AI 추천 미션을 App.js에 저장
navigate('/mission');
}
} catch (error) {
console.error('건강 데이터 로딩 오류:', error);
alert('건강 데이터를 불러오는 중 오류가 발생했습니다.');
}
};
const syncHealthCheckupData = async () => {
try {
const token = localStorage.getItem('authToken');
const response = await fetch(`${window.__runtime_config__?.HEALTH_URL}/checkup/history`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
console.log('🔍 API 원본 응답:', result);
// ✅ API 응답이 정상이면 원본 데이터만 최소한 정리해서 전달
if (result.data && result.data.userInfo && result.data.recentHealthProfile) {
console.log('✅ API 데이터 정상 - 원본 구조로 전달');
return result.data; // API 원본 구조 그대로 전달
}
// API 응답이 예상과 다르면 null 반환 (Dashboard에서 기본값 사용)
console.warn('⚠️ API 응답 구조가 예상과 다름 - null 반환하여 Dashboard 기본값 사용');
return null;
} catch (error) {
console.error('❌ 건강검진 동기화 API 오류:', error);
return null; // 에러 시 null 반환 (Dashboard에서 기본값 사용)
}
}
// LoadingPage.js에서 generateHealthDiagnosis 함수를 이것으로 교체하세요
const generateHealthDiagnosis = async (healthData, user) => {
try {
const token = localStorage.getItem('authToken');
const user_id = localStorage.getItem('userId') || 'temp-user-id';
const params = new URLSearchParams({
user_id: user_id
});
const response = await fetch(`${window.__runtime_config__?.INTELLIGENCE_URL}/health/diagnosis?${params}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
console.warn(`건강진단 API 에러: ${response.status} - 가라데이터 사용`);
return getDefaultDiagnosis();
}
const result = await response.json();
console.log('=== 건강진단 API 응답 디버깅 ===');
console.log('전체 응답:', result);
let diagnosis = null;
// ✅ Case 1: 이미 배열로 온 경우 (올바른 백엔드 구현)
if (result.threeSentenceSummary && Array.isArray(result.threeSentenceSummary)) {
console.log('✅ 배열 형태로 수신:', result.threeSentenceSummary);
diagnosis = result.threeSentenceSummary; // 있는 그대로만 사용
}
// ✅ Case 2: 문자열로 온 경우 (현재 백엔드 구현) - 응답 그대로만 분리
else if (result.threeSentenceSummary && typeof result.threeSentenceSummary === 'string') {
console.log('⚠️ 문자열 형태로 수신 - 배열로 변환:', result.threeSentenceSummary);
const originalText = result.threeSentenceSummary.trim();
let sentences = [];
// 🔧 방법 1: 마침표 + 공백으로 분리
sentences = originalText.split(/\.\s+/);
console.log('방법1 결과:', sentences);
// 🔧 방법 2: 마침표만으로 분리 (공백 없는 경우)
if (sentences.length <= 1) {
sentences = originalText.split(/\./);
console.log('방법2 결과:', sentences);
}
// 🔧 방법 3: 느낌표, 물음표도 포함
if (sentences.length <= 1) {
sentences = originalText.split(/[.!?]\s*/);
console.log('방법3 결과:', sentences);
}
// 🔧 방법 4: 줄바꿈으로 분리 시도
if (sentences.length <= 1) {
sentences = originalText.split(/\n+/);
console.log('방법4 결과:', sentences);
}
// 문장 정리
sentences = sentences
.map(s => s.trim())
.filter(s => s.length > 5) // 너무 짧은 문장 제거
.map(s => {
// 문장 끝에 마침표가 없으면 추가
if (s && !s.match(/[.!?]$/)) {
return s + '.';
}
return s;
});
console.log('최종 정리된 문장들:', sentences);
// ✅ API 응답 그대로만 사용 (부족해도 추가 안함)
if (sentences.length > 0) {
diagnosis = sentences; // 있는 것만 그대로 사용
console.log('✅ 문장 분리 성공 (응답 그대로):', diagnosis);
} else {
// 완전히 분리 실패한 경우만 원문 사용
console.log('⚠️ 문장 분리 실패, 원문 그대로 사용');
diagnosis = [originalText];
}
}
// ✅ 백업: 다른 키에서도 확인
if (!diagnosis) {
console.log('⚠️ threeSentenceSummary 없음, 다른 키 확인');
if (result.diagnosis && Array.isArray(result.diagnosis)) {
diagnosis = result.diagnosis;
} else if (result.data?.diagnosis && Array.isArray(result.data.diagnosis)) {
diagnosis = result.data.diagnosis;
} else if (result.data && Array.isArray(result.data)) {
diagnosis = result.data;
} else if (Array.isArray(result)) {
diagnosis = result;
}
}
console.log('🎯 최종 diagnosis 결과 (API 응답 그대로):', diagnosis);
// ✅ 최종 검증
if (diagnosis && Array.isArray(diagnosis) && diagnosis.length > 0) {
console.log(`✅ AI 건강진단 로드 성공! (${diagnosis.length}문장)`, diagnosis);
return diagnosis; // API 응답 그대로 반환
} else {
console.log('❌ 최종 파싱 실패 - 가라데이터 사용');
return getDefaultDiagnosis();
}
} catch (error) {
console.warn('건강진단 API 연결 실패 - 가라데이터 사용:', error.message);
return getDefaultDiagnosis();
}
};
// AI 미션 추천 API 호출 (신규회원 전용, POST 요청)
const generateAiMissions = async (healthData, user) => {
try {
// 신규회원이 아닌 경우 API 호출하지 않고 빈 배열 반환
if (!isNewUser) {
console.log('기존 회원 - AI 미션 추천 건너뛰기');
return [];
}
setLoadingMessage('AI가 건강 데이터를 분석하고 있습니다');
await new Promise(resolve => setTimeout(resolve, 800));
const token = localStorage.getItem('authToken');
const userId = localStorage.getItem('userId') || 'temp-user-id';
// POST 요청으로 userId만 body에 전송
const response = await fetch(`${window.__runtime_config__?.INTELLIGENCE_URL}/missions/recommend`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
user_id: parseInt(userId) // API 문서에 맞춰 user_id로 전송, integer 타입
})
});
if (!response.ok) {
if (response.status === 422) {
console.log('AI 미션 데이터 없음 (422) - 가라데이터 사용');
return getDefaultMissions();
} else if (response.status === 404) {
console.log('AI 미션 API 없음 (404) - 가라데이터 사용');
return getDefaultMissions();
} else {
console.warn(`AI 미션 추천 API 에러: ${response.status} - 가라데이터 사용`);
return getDefaultMissions();
}
}
const result = await response.json();
const missions = result.missions || result.data?.missions;
if (!missions || !Array.isArray(missions) || missions.length === 0) {
console.log('AI 미션 데이터 없음 - 가라데이터 사용');
return getDefaultMissions();
}
setLoadingMessage('개인 맞춤 미션이 완성되었습니다!');
await new Promise(resolve => setTimeout(resolve, 1000));
console.log('AI 미션 추천 데이터 로드 성공');
return missions;
} catch (error) {
console.warn('AI 미션 추천 API 연결 실패 - 가라데이터 사용:', error.message);
setLoadingMessage('기본 추천 미션을 준비하고 있습니다');
return getDefaultMissions();
}
};
// ✅ 수정된 목표 데이터 로딩 함수 - memberSerialNumber 파라미터 추가
const loadActiveGoals = async () => {
try {
const token = localStorage.getItem('authToken');
const memberSerialNumber = localStorage.getItem('memberSerialNumber') || localStorage.getItem('userId') || '1';
// ✅ memberSerialNumber 쿼리 파라미터 추가
const params = new URLSearchParams({
memberSerialNumber: memberSerialNumber
});
const response = await fetch(`${window.__runtime_config__?.GOAL_URL}/missions/active?${params}`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
return result.data || result;
} catch (error) {
console.error('목표 데이터 로딩 API 오류:', error);
// API 오류 시 기본 더미 데이터 반환(테스트용)
return {
water: { current: 3, target: 8, goalName: "물 마시기", description: "하루 8잔의 물 마시기" },
walk: { current: 5000, target: 10000, goalName: "걷기", description: "하루 1만보 걷기" },
stretch: { current: 2, target: 5, goalName: "스트레칭", description: "하루 5회 스트레칭" },
meditation: { current: 0, target: 1, goalName: "명상", description: "하루 10분 명상" },
sleep: { current: 0, target: 1, goalName: "충분한 수면", description: "하루 7시간 이상 수면" }
};
}
};
return (
<div className="loading-page">
<Header title="건강정보 연동" />
<div className="content">
<LoadingSpinner message={loadingMessage} />
<p style={{ color: '#666', marginBottom: '40px', textAlign: 'center' }}>
{isNewUser ?
'AI가 회원님의 건강 데이터를 분석하여 맞춤형 미션을 생성하는 중...' :
'건강보험공단과 연동하여 검진 이력을 불러오는 중...'
}
</p>
<div className="info-box">
<p>잠시만 기다려주세요!</p>
<p className="subtitle">
{isNewUser ? (
<>
AI가 회원님의 건강 상태를 분석하여<br />
맞춤형 건강 미션을 생성하고 있습니다.
</>
) : (
<>
기존 설정된 건강 미션과<br />
최신 건강 데이터를 불러오고 있습니다.
</>
)}
</p>
</div>
</div>
</div>
);
}
export default LoadingPage;

140
src/pages/LoginPage.js Normal file
View File

@ -0,0 +1,140 @@
//* src/pages/LoginPage.js
import React, { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
function LoginPage({ setUser }) {
const navigate = useNavigate();
// 백엔드 /api/auth/login으로 리다이렉트
const handleGoogleLogin = () => {
console.log('구글 로그인 요청 시작 - 백엔드로 리다이렉트');
// 백엔드의 /api/auth/login 엔드포인트로 리다이렉트
window.location.href = `${window.__runtime_config__?.AUTH_URL || 'http://localhost:8081'}/oauth2/authorization/google`;
};
// 구글 OAuth 완료 후 백엔드에서 돌아온 결과 처리
useEffect(() => {
const urlParams = new URLSearchParams(window.location.search);
// 새로운 파라미터명으로 변경
const success = urlParams.get('success');
const accessToken = urlParams.get('accessToken');
const refreshToken = urlParams.get('refreshToken');
const isNewUser = urlParams.get('isNewUser');
const userId = urlParams.get('userId');
const error = urlParams.get('error');
console.log('OAuth 파라미터:', {
success,
accessToken: accessToken ? accessToken.substring(0, 20) + '...' : null,
refreshToken: refreshToken ? refreshToken.substring(0, 20) + '...' : null,
isNewUser,
userId,
error
});
if (error) {
console.error('OAuth 에러:', error);
alert('로그인 중 오류가 발생했습니다: ' + decodeURIComponent(error));
// URL에서 에러 파라미터 제거
window.history.replaceState({}, document.title, window.location.pathname);
return;
}
// success가 'true'이고 accessToken이 있는 경우 로그인 성공
if (success === 'true' && accessToken) {
console.log('OAuth 로그인 성공');
// JWT 토큰들을 로컬 스토리지에 저장
localStorage.setItem('authToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
localStorage.setItem('isNewUser', isNewUser || 'false');
localStorage.setItem('userId', userId);
// 사용자 정보 설정 (일단 기본 정보만)
const userInfo = {
id: userId,
isNewUser: isNewUser === 'true'
};
setUser(userInfo);
console.log('사용자 정보 설정 완료:', userInfo);
// URL에서 파라미터들 제거 (보안상 좋음)
window.history.replaceState({}, document.title, window.location.pathname);
// 신규 회원인지 기존 회원인지에 따라 라우팅
if (isNewUser === 'true') {
console.log('신규 회원 - 회원가입 페이지로 이동');
navigate('/register');
} else {
console.log('기존 회원 - 로딩 페이지로 이동');
navigate('/loading');
}
}
}, [navigate, setUser]);
return (
<div className="login-page">
<div className="header">
<div className="title">HealthSync</div>
</div>
<div className="content">
<div className="logo">
<div className="logo-container">
<span className="logo-icon"></span>
</div>
<h2 style={{ textAlign: 'center', color: '#333', marginBottom: '16px', fontWeight: '600' }}>
AI 건강 코치와 함께
</h2>
<p style={{ color: '#666', marginBottom: '80px', textAlign: 'center', lineHeight: '1.5' }}>
개인 맞춤형 건강관리를<br />시작해보세요
</p>
</div>
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: '16px' }}>
<button
onClick={handleGoogleLogin}
style={{
width: '100%',
padding: '12px 16px',
border: '2px solid #e9ecef',
borderRadius: '8px',
background: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '12px',
fontSize: '16px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.3s'
}}
onMouseOver={(e) => {
e.target.style.borderColor = '#667eea';
e.target.style.boxShadow = '0 2px 10px rgba(102, 126, 234, 0.1)';
}}
onMouseOut={(e) => {
e.target.style.borderColor = '#e9ecef';
e.target.style.boxShadow = 'none';
}}
>
<svg width="20" height="20" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Google 계정으로 로그인
</button>
</div>
<p style={{ textAlign: 'center', color: '#999', fontSize: '14px', marginTop: '30px' }}>
간편하게 로그인하고 건강한 습관을 만들어보세요
</p>
</div>
</div>
);
}
export default LoginPage;

View File

@ -0,0 +1,177 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { useGoogleLogin } from '@react-oauth/google';
function LoginPage({ setUser }) {
const navigate = useNavigate();
// useGoogleLogin 훅을 사용한 Authorization Code Flow
const googleLogin = useGoogleLogin({
flow: 'auth-code', // Authorization Code Flow 사용
onSuccess: async (codeResponse) => {
try {
console.log('구글 로그인 성공:', codeResponse);
// Authorization Code 추출
const code = codeResponse.code;
if (!code) {
console.error('Authorization code를 받지 못했습니다.');
console.log('받은 응답:', codeResponse);
alert('구글 로그인 중 오류가 발생했습니다. 다시 시도해주세요.');
return;
}
console.log('구글 Authorization Code:', code);
// 백엔드 API에 code 값만 전송
const loginResult = await loginWithGoogleCode(code);
// isNewUser 정보를 localStorage에 저장
localStorage.setItem('isNewUser', loginResult.isNewUser.toString());
// 사용자 정보 설정
setUser(loginResult.user);
if (loginResult.isNewUser) {
// 신규 회원이면 회원가입 페이지로
console.log('신규 회원 가입');
navigate('/register');
} else {
// 기존 회원이면 바로 로딩 화면으로
console.log('기존 회원 로그인');
navigate('/loading');
}
} catch (error) {
console.error('구글 로그인 처리 중 오류:', error);
alert('로그인 중 오류가 발생했습니다. 다시 시도해주세요.');
}
},
onError: () => {
console.error('구글 로그인 실패');
alert('구글 로그인에 실패했습니다. 다시 시도해주세요.');
}
});
// 백엔드 API에 구글 Authorization Code만 전송
const loginWithGoogleCode = async (code) => {
try {
const response = await fetch(`${window.__runtime_config__?.AUTH_URL}/api/auth/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
code: code // 구글에서 받은 code 값만 전송
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
// 백엔드에서 처리할 작업:
// 1. code를 구글 OAuth 서버에 보내서 Access Token 받기
// 2. Access Token으로 구글에서 사용자 정보 조회
// 3. 사용자 정보로 회원 가입/로그인 처리
// 4. 우리 앱용 JWT 토큰 생성해서 응답
// API 응답 예시:
// {
// isNewUser: true/false,
// user: {
// id: "123",
// googleId: "구글ID",
// name: "홍길동",
// email: "hong@gmail.com",
// picture: "profile_image_url"
// },
// token: "eyJhbGciOiJIUzI1NiIs...", // 우리 앱의 JWT 토큰
// message: "로그인 성공"
// }
// 우리 앱의 JWT 토큰을 로컬 스토리지에 저장
if (result.token) {
localStorage.setItem('authToken', result.token);
console.log('JWT 토큰 저장 완료');
}
return {
isNewUser: result.isNewUser,
user: result.user,
token: result.token
};
} catch (error) {
console.error('로그인 API 오류:', error);
throw error;
}
};
return (
<div className="login-page">
<div className="header">
<div className="title">HealthSync</div>
</div>
<div className="content">
<div className="logo">
<div className="logo-container">
<span className="logo-icon">✓</span>
</div>
<h2 style={{ textAlign: 'center', color: '#333', marginBottom: '16px', fontWeight: '600' }}>
AI 건강 코치와 함께
</h2>
<p style={{ color: '#666', marginBottom: '80px', textAlign: 'center', lineHeight: '1.5' }}>
개인 맞춤형 건강관리를<br />시작해보세요
</p>
</div>
<div style={{ display: 'flex', justifyContent: 'center', marginBottom: '16px' }}>
<button
onClick={googleLogin}
style={{
width: '100%',
padding: '12px 16px',
border: '2px solid #e9ecef',
borderRadius: '8px',
background: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '12px',
fontSize: '16px',
fontWeight: '500',
cursor: 'pointer',
transition: 'all 0.3s'
}}
onMouseOver={(e) => {
e.target.style.borderColor = '#667eea';
e.target.style.boxShadow = '0 2px 10px rgba(102, 126, 234, 0.1)';
}}
onMouseOut={(e) => {
e.target.style.borderColor = '#e9ecef';
e.target.style.boxShadow = 'none';
}}
>
<svg width="20" height="20" viewBox="0 0 24 24">
<path fill="#4285F4" d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"/>
<path fill="#34A853" d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"/>
<path fill="#FBBC05" d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"/>
<path fill="#EA4335" d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"/>
</svg>
Google 계정으로 로그인
</button>
</div>
<p style={{ textAlign: 'center', color: '#999', fontSize: '14px', marginTop: '30px' }}>
간편하게 로그인하고 건강한 습관을 만들어보세요
</p>
</div>
</div>
);
}
export default LoginPage;

329
src/pages/MissionPage.js Normal file
View File

@ -0,0 +1,329 @@
//* src/pages/MissionPage.js
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Header from '../components/Header';
import MissionCard from '../components/MissionCard';
function MissionPage({ selectedMissions, setSelectedMissions, setGoalData, aiMissions, healthData, healthDiagnosis }) {
const navigate = useNavigate();
const [isSubmitting, setIsSubmitting] = useState(false);
// 🔧 AI 추천 미션을 MissionCard 형식으로 변환 (UI 표시용만)
const convertAiMissionsToCards = (aiMissions) => {
if (!aiMissions || !Array.isArray(aiMissions) || aiMissions.length === 0) {
console.log('AI 미션이 없어서 기본 미션 사용');
return getDefaultMissions();
}
console.log('=== AI 미션 변환 (UI용만) ===');
console.log('원본 AI 미션들:', aiMissions);
return aiMissions.map((mission, index) => {
// missionId 생성 (없으면 인덱스 기반으로)
const missionId = mission.missionId || `ai_mission_${index + 1}`;
// 카테고리에 따른 이모지 매핑
const getCategoryEmoji = (category) => {
switch (category?.toLowerCase()) {
case 'nutrition': return '💧';
case 'exercise': return '🚶‍♂️';
case 'sleep': return '😴';
case 'mental_health': return '🧘‍♀️';
default: return '🎯';
}
};
const convertedMission = {
id: missionId,
title: `${getCategoryEmoji(mission.category)} ${mission.title}`,
description: mission.reason || '건강한 생활습관을 위한 미션입니다.',
dailyTarget: mission.dailyTargetCount || mission.estimatedTimeMinutes || 1,
reason: mission.healthBenefit || mission.occupationRelevance || '건강 개선을 위한 추천 미션',
category: mission.category,
difficulty: mission.difficulty,
estimatedTimeMinutes: mission.estimatedTimeMinutes,
originalData: mission // ✅ 원본 AI 미션 데이터 보존
};
console.log(`변환된 미션 ${index + 1}:`, convertedMission);
return convertedMission;
});
};
// 기본 미션 (AI 추천이 없을 때 사용)
const getDefaultMissions = () => {
return [
{
id: 'water',
title: '💧 하루 8잔 물마시기',
description: '충분한 수분 섭취로 신진대사를 활발하게 하고 피부 건강을 개선합니다.',
dailyTarget: 8,
reason: '장시간 앉아 있는 직업군에게 필수적인 수분 공급으로 신진대사 개선',
originalData: {
title: '하루 8잔 물마시기',
dailyTargetCount: 8,
healthBenefit: '장시간 앉아 있는 직업군에게 필수적인 수분 공급으로 신진대사 개선',
category: 'nutrition',
difficulty: 'easy'
}
},
{
id: 'walk',
title: '🚶‍♂️ 점심시간 10분 산책',
description: '장시간 앉아있는 직업 특성을 고려한 혈액순환 개선 운동입니다.',
dailyTarget: 1,
reason: '혈압과 혈당 관리, 체중 감량을 위한 유산소 운동',
originalData: {
title: '점심시간 10분 산책',
dailyTargetCount: 1,
healthBenefit: '혈압과 혈당 관리, 체중 감량을 위한 유산소 운동',
category: 'exercise',
difficulty: 'easy'
}
},
{
id: 'stretch',
title: '🧘‍♀️ 5분 목&어깨 스트레칭',
description: '컴퓨터 작업으로 인한 목과 어깨 긴장을 완화하는 스트레칭입니다.',
dailyTarget: 3,
reason: '장시간 모니터 사용으로 인한 목 긴장 완화 및 거북목 예방',
originalData: {
title: '5분 목&어깨 스트레칭',
dailyTargetCount: 3,
healthBenefit: '장시간 모니터 사용으로 인한 목 긴장 완화 및 거북목 예방',
category: 'exercise',
difficulty: 'easy'
}
},
{
id: 'snack',
title: '🥗 하루 1회 건강간식',
description: '과일이나 견과류로 건강한 간식 습관을 만들어보세요.',
dailyTarget: 1,
reason: '혈당 조절과 영양 균형을 위한 건강한 간식 섭취',
originalData: {
title: '하루 1회 건강간식',
dailyTargetCount: 1,
healthBenefit: '혈당 조절과 영양 균형을 위한 건강한 간식 섭취',
category: 'nutrition',
difficulty: 'easy'
}
},
{
id: 'sleep',
title: '😴 규칙적인 수면시간',
description: '매일 같은 시간에 잠자리에 들어 생체리듬을 조절합니다.',
dailyTarget: 1,
reason: '스트레스 관리와 면역력 강화를 위한 규칙적인 수면 패턴',
originalData: {
title: '규칙적인 수면시간',
dailyTargetCount: 1,
healthBenefit: '스트레스 관리와 면역력 강화를 위한 규칙적인 수면 패턴',
category: 'sleep',
difficulty: 'normal'
}
}
];
};
// 🔧 실제 사용할 미션들 결정
const missions = convertAiMissionsToCards(aiMissions);
console.log('=== MissionPage 미션 데이터 ===');
console.log('받은 aiMissions:', aiMissions);
console.log('최종 사용할 missions:', missions);
console.log('AI 미션 사용 여부:', aiMissions && aiMissions.length > 0);
const handleMissionToggle = (missionId) => {
setSelectedMissions(prev => {
if (prev.includes(missionId)) {
return prev.filter(id => id !== missionId);
} else {
return [...prev, missionId];
}
});
};
const handleStart = async () => {
if (selectedMissions.length === 0) {
alert('최소 1개의 미션을 선택해주세요.');
return;
}
setIsSubmitting(true);
try {
// ✅ 선택된 미션들의 원본 데이터를 그대로 사용
const selectedMissionData = selectedMissions.map(missionId => {
const mission = missions.find(m => m.id === missionId);
// ✅ originalData가 있으면 그대로 사용, 없으면 기본 형식으로 변환
if (mission.originalData) {
return mission.originalData;
} else {
// 기본 미션의 경우 기본 형식으로 변환
return {
title: mission.title.replace(/^.+\s/, ''), // 이모지 제거
dailyTargetCount: mission.dailyTarget,
healthBenefit: mission.reason,
category: mission.category,
difficulty: mission.difficulty
};
}
});
console.log('✅ 원본 AI 미션 데이터 그대로 전송:', selectedMissionData);
// 미션 선택 API 호출
await submitSelectedMissions(selectedMissionData);
// 선택된 미션을 기반으로 초기 목표 데이터 설정
const initialGoalData = {};
selectedMissions.forEach(missionId => {
const mission = missions.find(m => m.id === missionId);
initialGoalData[missionId] = {
current: 0,
target: mission.dailyTarget,
goalName: mission.title.replace(/^.+\s/, ''), // 이모지 제거
description: mission.description
};
});
console.log('생성된 목표 데이터:', initialGoalData);
// App.js의 goalData 상태 업데이트
setGoalData(initialGoalData);
// 신규 사용자 플래그 제거 (이제 기존 사용자가 됨)
localStorage.setItem('isNewUser', 'false');
// 대시보드로 이동
navigate('/dashboard');
} catch (error) {
console.error('미션 설정 오류:', error);
alert('미션 설정 중 오류가 발생했습니다. 다시 시도해주세요.');
} finally {
setIsSubmitting(false);
}
};
const submitSelectedMissions = async (selectedMissionData) => {
try {
const token = localStorage.getItem('authToken');
const memberSerialNumber = localStorage.getItem('memberSerialNumber') ||
localStorage.getItem('userId') || '1';
// ✅ AI 추천 미션 원본 데이터를 그대로 전송
const requestData = {
memberSerialNumber: parseInt(memberSerialNumber),
selectedMissionIds: selectedMissionData // ✅ 원본 데이터 그대로 사용
};
console.log('=== 미션 선택 API 요청 (원본 데이터) ===');
console.log('📋 요청 데이터:', JSON.stringify(requestData, null, 2));
console.log('📊 선택된 미션 개수:', requestData.selectedMissionIds.length);
const response = await fetch(`${window.__runtime_config__?.GOAL_URL}/missions/select`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
},
body: JSON.stringify(requestData)
});
if (!response.ok) {
const errorText = await response.text();
console.error('❌ 미션 선택 API 오류:', response.status, errorText);
throw new Error(`미션 선택 API 오류: ${response.status}`);
}
const result = await response.json();
console.log('✅ 미션 선택 API 성공:', result);
return result;
} catch (error) {
console.error('💥 미션 선택 API 호출 실패:', error);
throw error;
}
};
return (
<div className="mission-page">
<Header title="AI 추천 건강 미션" showBack backPath="/loading" />
<div className="content">
{/* 🔥 건강진단 요약 표시 */}
{healthDiagnosis && healthDiagnosis.length > 0 && (
<div style={{
backgroundColor: '#f8f9ff',
padding: '16px',
borderRadius: '8px',
marginBottom: '24px',
border: '1px solid #e0e6ff'
}}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '12px' }}>
<span style={{ fontSize: '18px', marginRight: '8px' }}>🤖</span>
<h4 style={{ margin: 0, color: '#667eea' }}>AI 건강 분석 결과</h4>
</div>
<div style={{ fontSize: '14px', lineHeight: '1.5', color: '#333' }}>
{healthDiagnosis.map((line, index) => (
<p key={index} style={{ margin: '4px 0' }}>{line}</p>
))}
</div>
</div>
)}
<p style={{
color: '#666',
marginBottom: '24px',
textAlign: 'center',
fontSize: '15px'
}}>
{aiMissions && aiMissions.length > 0 ? (
<>
회원님의 건강검진 결과와 직업 특성을 분석하여<br />
<strong style={{ color: '#667eea' }}>AI가 맞춤 추천한</strong> .
</>
) : (
<>
회원님의 건강을 위한<br />
추천 건강 미션을 선택해주세요.
</>
)}
</p>
{missions.map(mission => (
<MissionCard
key={mission.id}
mission={mission}
isSelected={selectedMissions.includes(mission.id)}
onToggle={() => handleMissionToggle(mission.id)}
/>
))}
<p style={{ color: '#667eea', fontSize: '14px', margin: '20px 0' }}>
최소 1개의 미션을 선택해주세요 (다중 선택 가능)
{aiMissions && aiMissions.length > 0 && (
<>
<br />
<span style={{ color: '#28a745' }}>
미션들은 AI가 회원님의 건강 상태를 분석하여 개인 맞춤 추천한 미션입니다
</span>
</>
)}
</p>
<button
className="btn btn-primary"
onClick={handleStart}
disabled={isSubmitting}
>
{isSubmitting ? '설정 중...' : '시작하기'}
</button>
</div>
</div>
);
}
export default MissionPage;

View File

@ -0,0 +1,113 @@
//* src/pages/OAuthCallbackPage.js
import { useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
function OAuthCallbackPage({ setUser }) {
const navigate = useNavigate();
useEffect(() => {
// 페이지 로드 즉시 화면 숨김
document.body.style.display = 'none';
document.documentElement.style.display = 'none';
console.log('OAuth 콜백 페이지 로드됨');
// JSON 응답 처리
const handleOAuthResponse = () => {
try {
const bodyText = document.body.textContent || document.body.innerText || '';
console.log('페이지 내용:', bodyText.substring(0, 200));
// JSON 응답인지 확인
if (bodyText.includes('"accessToken"') || bodyText.includes('"success"') ||
(bodyText.trim().startsWith('{') && bodyText.includes('memberSerialNumber'))) {
console.log('JSON 응답 감지!');
try {
// JSON 파싱
const response = JSON.parse(bodyText.trim());
console.log('파싱된 응답:', response);
const data = response.data || response;
console.log('데이터:', data);
if (data.accessToken) {
console.log('로그인 성공! 토큰 처리 시작');
// 토큰 localStorage에 저장
localStorage.setItem('authToken', data.accessToken);
if (data.refreshToken) {
localStorage.setItem('refreshToken', data.refreshToken);
}
// 사용자 정보 설정 및 저장
if (data.user) {
localStorage.setItem('userInfo', JSON.stringify(data.user));
setUser(data.user);
// userId 별도 저장 (API 호출용)
if (data.user.memberSerialNumber) {
localStorage.setItem('userId', data.user.memberSerialNumber.toString());
}
}
// newUser 값으로 분기 처리
const isNewUser = data.newUser;
console.log('신규 사용자 여부:', isNewUser);
if (isNewUser === true) {
console.log('신규 회원 → 회원가입 페이지로 이동');
navigate('/register');
} else {
console.log('기존 회원 → 로딩 페이지로 이동');
navigate('/loading');
}
} else {
console.error('accessToken이 없습니다:', data);
alert('로그인 처리 중 오류가 발생했습니다.');
navigate('/');
}
} catch (parseError) {
console.error('JSON 파싱 오류:', parseError);
alert('로그인 응답 처리 중 오류가 발생했습니다.');
navigate('/');
}
} else {
console.log('JSON 응답이 아직 로드되지 않음');
}
} catch (error) {
console.error('OAuth 처리 중 오류:', error);
alert('로그인 처리 중 오류가 발생했습니다.');
navigate('/');
}
};
// 즉시 실행 및 재시도
handleOAuthResponse();
const intervals = [10, 50, 100, 200, 500, 1000];
const timeouts = intervals.map(delay =>
setTimeout(handleOAuthResponse, delay)
);
// MutationObserver로 DOM 변경 감지
const observer = new MutationObserver(handleOAuthResponse);
observer.observe(document.body, {
childList: true,
subtree: true,
characterData: true
});
// cleanup
return () => {
timeouts.forEach(clearTimeout);
observer.disconnect();
};
}, [navigate, setUser]);
// 화면에는 아무것도 렌더링하지 않음 (이미 숨김 처리됨)
return null;
}
export default OAuthCallbackPage;

162
src/pages/RegisterPage.js Normal file
View File

@ -0,0 +1,162 @@
//* src/pages/RegisterPage.js
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Header from '../components/Header';
function RegisterPage({ setUser }) {
const navigate = useNavigate();
const [formData, setFormData] = useState({
name: '',
birthDate: '',
occupation: ''
});
const [isLoading, setIsLoading] = useState(false);
const handleInputChange = (e) => {
setFormData({
...formData,
[e.target.name]: e.target.value
});
};
const handleSubmit = async (e) => {
e.preventDefault();
setIsLoading(true);
try {
// 회원가입 API 호출
await registerUser(formData);
// 회원정보 저장
setUser(prev => ({ ...prev, ...formData }));
console.log('회원가입 성공');
navigate('/loading');
} catch (error) {
console.error('회원가입 오류:', error);
alert('회원가입 중 오류가 발생했습니다. 다시 시도해주세요.');
} finally {
setIsLoading(false);
}
};
const registerUser = async (userData) => {
try {
// 저장된 JWT 토큰 가져오기
const token = localStorage.getItem('authToken');
// 토큰 확인 로그
console.log('저장된 토큰:', token ? token.substring(0, 50) + '...' : 'null');
console.log('회원가입 API 호출:', {
url: `${window.__runtime_config__?.USER_URL || 'http://localhost:8081'}/register`,
userData,
hasToken: !!token
});
const response = await fetch(`${window.__runtime_config__?.USER_URL || 'http://localhost:8081'}/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify({
name: userData.name,
birthDate: userData.birthDate,
occupation: userData.occupation
})
});
if (!response.ok) {
const errorText = await response.text();
console.error('회원가입 API 에러:', response.status, errorText);
throw new Error(`HTTP error! status: ${response.status} - ${errorText}`);
}
const result = await response.json();
console.log('회원가입 API 성공:', result);
return result;
} catch (error) {
console.error('회원가입 API 오류:', error);
throw error;
}
};
const handleCancel = () => {
navigate('/');
};
return (
<div className="register-page">
<Header title="회원정보 입력" showBack backPath="/" />
<div className="content">
<form onSubmit={handleSubmit}>
<div className="form-group">
<label className="form-label">이름</label>
<input
type="text"
name="name"
className="form-input"
placeholder="이름을 입력하세요"
value={formData.name}
onChange={handleInputChange}
required
/>
</div>
<div className="form-group">
<label className="form-label">생년월일</label>
<input
type="date"
name="birthDate"
className="form-input"
value={formData.birthDate}
onChange={handleInputChange}
required
/>
</div>
<div className="form-group">
<label className="form-label">직업군</label>
<select
name="occupation"
className="form-select"
value={formData.occupation}
onChange={handleInputChange}
required
>
<option value="">직업을 선택하세요</option>
<option value="개발">IT 개발</option>
<option value="PM">PM (Product Manager)</option>
<option value="마케팅">마케팅</option>
<option value="영업">영업</option>
<option value="인프라운영">인프라운영</option>
<option value="고객상담">고객상담</option>
</select>
</div>
<button
type="submit"
className="btn btn-primary"
disabled={isLoading}
>
{isLoading ? '가입 중...' : '가입하기'}
</button>
<button
type="button"
className="btn btn-secondary"
onClick={handleCancel}
disabled={isLoading}
>
취소
</button>
</form>
</div>
</div>
);
}
export default RegisterPage;

7
vite.config.js Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
})