diff --git a/frontend/src/app/api/report.ts b/frontend/src/app/api/report.ts new file mode 100644 index 0000000..e22d92c --- /dev/null +++ b/frontend/src/app/api/report.ts @@ -0,0 +1,144 @@ +const API_BASE = import.meta.env.VITE_API_BASE_URL; +const TOKEN_KEY = 'hyfive_token'; + +// ── Types ────────────────────────────────────────────────────────────────────── + +export type VaccinationCode = + | 'DHPPL' + | 'RABIES' + | 'KENNEL_COUGH' + | 'CORONA_ENTERITIS' + | 'HEARTWORM' + | 'PARASITE'; + +export type AlertType = + | 'HEARTWORM_DUE_THIS_MONTH' + | 'DHPPL_DUE_THIS_MONTH' + | 'RABIES_DUE_THIS_MONTH' + | 'KENNEL_COUGH_DUE_THIS_MONTH' + | 'CORONA_ENTERITIS_DUE_THIS_MONTH' + | 'PARASITE_DUE_THIS_MONTH' + | 'WALK_RECORD_MISSING'; + +export interface ReportPet { + petId: number; + name: string; + breed: string; + ageYears: number | null; + profileImageUrl: string | null; +} + +export interface ReportMonthlyVisit { + month: string; // "YYYY-MM" + count: number; +} + +export interface ReportMedical { + totalVisitCount: number; + monthlyVisits: ReportMonthlyVisit[]; +} + +export interface ReportWalkTrend { + weekStart: string; // "YYYY-MM-DD" (월요일) + minutes: number; +} + +export interface ReportLifestyle { + weeklyAverageWalkMinutes: number; + walkTrend: ReportWalkTrend[]; + latestWeightKg: number | null; + weightChangeKg: number | null; +} + +export interface ReportVaccination { + code: VaccinationCode; + lastVaccinationDate: string; // "YYYY-MM-DD" +} + +export interface ReportAlert { + type: AlertType | string; + message: string; + detail: string; + actionPath: string; +} + +export interface Report { + pet: ReportPet; + medical: ReportMedical; + lifestyle: ReportLifestyle; + vaccinations: ReportVaccination[]; + alerts: ReportAlert[]; +} + +// ── Error classes ────────────────────────────────────────────────────────────── + +export class ReportApiError extends Error { + constructor( + public status: number, + public code: string, + message: string, + ) { + super(message); + this.name = 'ReportApiError'; + } +} + +export class ReportNetworkError extends Error { + constructor( + public cause: unknown, + public url: string, + ) { + super('Failed to reach the API server'); + this.name = 'ReportNetworkError'; + } +} + +// ── Internals ────────────────────────────────────────────────────────────────── + +interface ApiEnvelope { + data: T | null; + error: { code: string; message: string } | null; + meta: unknown; +} + +function authHeaders(): HeadersInit { + const token = localStorage.getItem(TOKEN_KEY); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +async function safeFetch(url: string, init?: RequestInit): Promise { + try { + return await fetch(url, init); + } catch (e) { + throw new ReportNetworkError(e, url); + } +} + +async function parseEnvelope(res: Response): Promise { + let body: ApiEnvelope | null = null; + try { + body = (await res.json()) as ApiEnvelope; + } catch { + throw new ReportApiError(res.status, 'BAD_RESPONSE', `HTTP ${res.status} (non-JSON body)`); + } + if (!res.ok || body.error) { + throw new ReportApiError( + res.status, + body.error?.code ?? 'UNKNOWN', + body.error?.message ?? `HTTP ${res.status}`, + ); + } + if (body.data === null) { + throw new ReportApiError(res.status, 'EMPTY_RESPONSE', 'Empty response'); + } + return body.data; +} + +// ── Public API functions ─────────────────────────────────────────────────────── + +export async function getReport(petId: number): Promise { + const res = await safeFetch(`${API_BASE}/api/report?petId=${petId}`, { + headers: authHeaders(), + }); + return parseEnvelope(res); +} diff --git a/frontend/src/app/components/HealthReport.tsx b/frontend/src/app/components/HealthReport.tsx index fc32eb0..38c7a68 100644 --- a/frontend/src/app/components/HealthReport.tsx +++ b/frontend/src/app/components/HealthReport.tsx @@ -1,3 +1,4 @@ +import { useState, useEffect } from 'react'; import { useNavigate } from 'react-router'; import MobileFrame from './MobileFrame'; import BottomNav from './BottomNav'; @@ -5,31 +6,72 @@ import { BarChart, Bar, XAxis, YAxis, ResponsiveContainer, Cell, Tooltip, LineChart, Line, CartesianGrid, } from 'recharts'; -import { AlertTriangle, ChevronRight } from 'lucide-react'; - -const visitData = [ - { id: 'v-1', month: '1월', count: 1 }, - { id: 'v-2', month: '2월', count: 0 }, - { id: 'v-3', month: '3월', count: 3 }, -]; - -const walkTrendData = [ - { week: '1주', minutes: 20 }, - { week: '2주', minutes: 18 }, - { week: '3주', minutes: 28 }, - { week: '4주', minutes: 25 }, -]; - -const preventionItems = [ - { label: '종합백신 (DHPPL)', status: '접종 완료', detail: '다음 접종까지 87일', dot: '#4CAF50', textColor: '#2E7D32' }, - { label: '광견병', status: '접종 완료', detail: '다음 접종까지 201일', dot: '#4CAF50', textColor: '#2E7D32' }, - { label: '심장사상충', status: '이번 달 투약 필요', detail: '마지막 투약 후 32일 경과', dot: '#FFA726', textColor: '#E65100' }, -]; - -const alertItems = [ - { id: 1, icon: '💊', text: '심장사상충 예방약 투약 시기입니다', sub: '마지막 투약 후 32일 경과', color: '#E65100', bg: '#FFF8E1', border: '#FFE082' }, - { id: 2, icon: '📝', text: '이번 주 산책 기록이 아직 없습니다', sub: '어제 기준 미입력 3일', color: '#616161', bg: '#F5F5F5', border: '#E0E0E0' }, -]; +import { ChevronRight } from 'lucide-react'; +import { + getReport, + Report, + VaccinationCode, + ReportApiError, +} from '@/app/api/report'; + +// ── Constants ────────────────────────────────────────────────────────────────── + +const VACCINE_CYCLE_DAYS: Record = { + DHPPL: 365, + RABIES: 365, + KENNEL_COUGH: 365, + CORONA_ENTERITIS: 365, + HEARTWORM: 30, + PARASITE: 90, +}; + +const VACCINE_LABELS: Record = { + DHPPL: '종합백신 (DHPPL)', + RABIES: '광견병', + KENNEL_COUGH: '켄넬코프', + CORONA_ENTERITIS: '코로나장염', + HEARTWORM: '심장사상충', + PARASITE: '외부기생충', +}; + +const ALERT_INFO: Record = { + HEARTWORM_DUE_THIS_MONTH: { icon: '💊', text: '심장사상충 예방약 투약 시기입니다', color: '#E65100', bg: '#FFF8E1', border: '#FFE082' }, + DHPPL_DUE_THIS_MONTH: { icon: '💉', text: '종합백신 (DHPPL) 접종 시기입니다', color: '#E65100', bg: '#FFF8E1', border: '#FFE082' }, + RABIES_DUE_THIS_MONTH: { icon: '💉', text: '광견병 접종 시기입니다', color: '#E65100', bg: '#FFF8E1', border: '#FFE082' }, + KENNEL_COUGH_DUE_THIS_MONTH: { icon: '💉', text: '켄넬코프 접종 시기입니다', color: '#E65100', bg: '#FFF8E1', border: '#FFE082' }, + CORONA_ENTERITIS_DUE_THIS_MONTH: { icon: '💉', text: '코로나장염 접종 시기입니다', color: '#E65100', bg: '#FFF8E1', border: '#FFE082' }, + PARASITE_DUE_THIS_MONTH: { icon: '💊', text: '외부기생충 예방약 투약 시기입니다', color: '#E65100', bg: '#FFF8E1', border: '#FFE082' }, + WALK_RECORD_MISSING: { icon: '🐾', text: '이번 주 산책 기록이 없습니다', color: '#616161', bg: '#F5F5F5', border: '#E0E0E0' }, +}; + +// ── Helpers ──────────────────────────────────────────────────────────────────── + +function formatMonth(yearMonth: string): string { + const [, m] = yearMonth.split('-'); + return `${parseInt(m, 10)}월`; +} + +function getVaccinationStatus(code: VaccinationCode, lastDate: string) { + const next = new Date(lastDate); + next.setDate(next.getDate() + VACCINE_CYCLE_DAYS[code]); + const daysUntilNext = Math.ceil((next.getTime() - Date.now()) / (1000 * 60 * 60 * 24)); + + if (daysUntilNext > 30) { + return { status: '접종 완료', detail: `다음 접종까지 ${daysUntilNext}일`, dot: '#4CAF50', textColor: '#2E7D32' }; + } + if (daysUntilNext > 0) { + return { status: '이번 달 접종 필요', detail: `다음 접종까지 ${daysUntilNext}일`, dot: '#FFA726', textColor: '#E65100' }; + } + return { status: '접종 기한 초과', detail: `${Math.abs(daysUntilNext)}일 경과`, dot: '#EF5350', textColor: '#C62828' }; +} + +function formatWeightChange(delta: number): { label: string; color: string; bg: string } { + if (delta > 0) return { label: `+${delta}kg`, color: '#F57C00', bg: '#FFF8E1' }; + if (delta < 0) return { label: `${delta}kg`, color: '#1976D2', bg: '#E3F2FD' }; + return { label: '변동 없음', color: '#616161', bg: '#F5F5F5' }; +} + +// ── Tooltip components ───────────────────────────────────────────────────────── const VisitTooltip = ({ active, payload }: any) => { if (active && payload?.length) { @@ -53,8 +95,78 @@ const WalkTooltip = ({ active, payload }: any) => { return null; }; +// ── Component ────────────────────────────────────────────────────────────────── + export default function HealthReport() { const navigate = useNavigate(); + const [report, setReport] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const petId = Number(localStorage.getItem('hyfive_petId')); + if (!petId) { + setError('활성화된 반려동물을 찾을 수 없습니다.'); + setLoading(false); + return; + } + + getReport(petId) + .then(setReport) + .catch((err) => { + if (err instanceof ReportApiError && err.status === 404) { + setError('등록된 반려동물이 없습니다.'); + } else { + setError('리포트를 불러오지 못했습니다. 잠시 후 다시 시도해 주세요.'); + } + }) + .finally(() => setLoading(false)); + }, []); + + const now = new Date(); + const reportMonth = `${now.getFullYear()}년 ${now.getMonth() + 1}월`; + + if (loading) { + return ( + +
+
+

리포트 불러오는 중...

+
+ + ); + } + + if (error || !report) { + return ( + +
+
+

🐾

+

+ {error ?? '리포트를 불러올 수 없습니다.'} +

+
+ +
+
+ ); + } + + const visitData = report.medical.monthlyVisits.map(v => ({ + month: formatMonth(v.month), + count: v.count, + })); + + const walkTrendData = report.lifestyle.walkTrend.map((w, i) => ({ + week: `${i + 1}주`, + minutes: w.minutes, + })); + + const { latestWeightKg, weightChangeKg, weeklyAverageWalkMinutes } = report.lifestyle; return ( @@ -62,16 +174,21 @@ export default function HealthReport() { {/* Pet profile bar */}
-
- 🐶 +
+ {report.pet.profileImageUrl + ? {report.pet.name} + : 🐶 + }
-

코코

-

골든 리트리버 · 만 4세

+

{report.pet.name}

+

+ {report.pet.breed}{report.pet.ageYears != null ? ` · 만 ${report.pet.ageYears}세` : ''} +

건강 리포트

-

2026년 3월

+

{reportMonth}

@@ -86,44 +203,29 @@ export default function HealthReport() {

최근 3개월 진료 현황

- {/* Stats row */} -
-
-

4회

+
+
+

{report.medical.totalVisitCount}회

진료 횟수

-
-

주요 진료

-

피부과 3회

-

내과 1회

-
- {/* Bar chart */} -
+
- - - - - } cursor={{ fill: 'rgba(27,75,140,0.05)' }} /> - - {visitData.map((_, index) => ( - - ))} - - - + + + + + } cursor={{ fill: 'rgba(27,75,140,0.05)' }} /> + + {visitData.map((_, index) => ( + + ))} + + +
- - {/* Insight */} -
- -

- 소형견 평균 대비 2.3배 높음 · 피부과 방문 증가 추세 -

-
{/* Section 2: 생활 데이터 추이 */} @@ -132,81 +234,113 @@ export default function HealthReport() {

이번 주 생활 기록

- {/* Metric rows */} -
- {[ - { label: '산책', value: '하루 평균 25분', compare: '권장 대비 83%', compareColor: '#1B4B8C', compareBg: '#E8F0FA', emoji: '🚶' }, - { label: '음수량', value: '하루 평균 180ml', compare: '권장 대비 72%', compareColor: '#F57C00', compareBg: '#FFF8E1', emoji: '💧' }, - { label: '체중', value: '4.2 kg', compare: '지난달 대비 +0.1kg', compareColor: '#FFA726', compareBg: '#FFF8E1', emoji: '⚖️' }, - ].map((m, i) => ( -
- {m.emoji} -
-

{m.label}

-

{m.value}

-
- - {m.compare} - +
+ {/* Walk */} +
+ 🚶 +
+

산책

+

하루 평균 {weeklyAverageWalkMinutes}분

- ))} +
+ + {/* Weight */} +
+ ⚖️ +
+

체중

+

+ {latestWeightKg != null ? `${latestWeightKg} kg` : '기록 없음'} +

+
+ {weightChangeKg != null && (() => { + const wc = formatWeightChange(weightChangeKg); + return ( + + {wc.label} + + ); + })()} +
- {/* Line chart */}

4주 산책 시간 추이 (분)

- - - - - - } /> - - - + + + + + + } /> + + +
- {/* Section 3: 예방 현황 */} -
-
-

예방접종 현황

-
-
- {preventionItems.map((item, i) => ( -
-
-
-

{item.label}

-

{item.detail}

-
- {item.status} -
- ))} + {/* Section 3: 예방접종 현황 */} + {report.vaccinations.length > 0 && ( +
+
+

예방접종 현황

+
+
+ {report.vaccinations.map((v, i) => { + const vs = getVaccinationStatus(v.code, v.lastVaccinationDate); + return ( +
+
+
+

{VACCINE_LABELS[v.code]}

+

{vs.detail}

+
+ {vs.status} +
+ ); + })} +
-
+ )} {/* Section 4: 최근 알림 */} -
-

최근 알림

-
- {alertItems.map(alert => ( -
- {alert.icon} -
-

{alert.text}

-

{alert.sub}

-
- -
- ))} + {report.alerts.length > 0 && ( +
+

최근 알림

+
+ {report.alerts.map((alert, i) => { + const info = ALERT_INFO[alert.type] ?? { + icon: '🔔', + text: alert.message, + color: '#616161', + bg: '#F5F5F5', + border: '#E0E0E0', + }; + return ( +
+ {info.icon} +
+

{info.text}

+

{alert.detail}

+
+ +
+ ); + })} +
-
+ )}
@@ -216,4 +350,4 @@ export default function HealthReport() {
); -} \ No newline at end of file +}