From 7468332cb063e21b8b115cc31a045914e611179d Mon Sep 17 00:00:00 2001 From: RosieOh Date: Fri, 25 Sep 2026 19:27:58 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=95=84=EC=9D=B4=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=EC=97=90=20=ED=95=A0=20=EC=9D=BC=20=ED=83=80=EC=9E=84=EB=9D=BC?= =?UTF-8?q?=EC=9D=B8=20=ED=83=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 접종은 접종 탭, 검진은 건강 기록, 지원금 마감은 정책 화면에 흩어져 있어서 "다음에 뭘 해야 하나" 를 알려면 화면 세 곳을 돌아야 했다. 서버가 한 축으로 합쳐 주므로 (CareCode_Interface #128) 아이 상세에서 바로 보여 준다. - 새 라우트를 만들지 않고 아이 상세의 첫 탭으로 뒀다. 이미 그 화면에 와 있는 사용자가 이동 없이 볼 수 있고, 이 질문이 아이 상세에서 가장 먼저 나오는 질문이다. - 놓친 일은 목록 위에 따로 알린다. 목록에 섞으면 지나친다. - type·status 는 문자열로 받고 표시할 때 매핑한다. 서버가 항목을 늘려도 화면이 죽지 않고 모르는 값은 원본을 그대로 보여 준다(테스트로 고정). - 검진·신학기가 권장 시기 안내임을 목록 아래에 밝힌다. --- openapi/openapi.json | 113 ++++++++++++++++++ src/apis/child.ts | 17 +++ .../children/[childId]/page.tsx | 58 ++++++++- .../features/child/TimelineItem.tsx | 75 ++++++++++++ .../child/__tests__/TimelineItem.test.tsx | 66 ++++++++++ src/queries/child.ts | 16 +++ .../__tests__/contracts-alignment.test.ts | 51 ++++++++ src/types/apis/child.ts | 44 +++++++ 8 files changed, 439 insertions(+), 1 deletion(-) create mode 100644 src/components/features/child/TimelineItem.tsx create mode 100644 src/components/features/child/__tests__/TimelineItem.test.tsx diff --git a/openapi/openapi.json b/openapi/openapi.json index 6380429..59eea87 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -1269,6 +1269,44 @@ }, "type" : "object" }, + "ChildTimelineResponse" : { + "properties" : { + "birthDate" : { + "format" : "date", + "type" : "string" + }, + "childId" : { + "format" : "int64", + "type" : "integer" + }, + "childName" : { + "type" : "string" + }, + "from" : { + "format" : "date", + "type" : "string" + }, + "items" : { + "items" : { + "$ref" : "#/components/schemas/TimelineItem" + }, + "type" : "array" + }, + "overdueCount" : { + "format" : "int32", + "type" : "integer" + }, + "to" : { + "format" : "date", + "type" : "string" + }, + "upcomingCount" : { + "format" : "int32", + "type" : "integer" + } + }, + "type" : "object" + }, "Cohort" : { "properties" : { "day1" : { @@ -3846,6 +3884,34 @@ }, "type" : "object" }, + "TimelineItem" : { + "properties" : { + "ageMonths" : { + "format" : "int32", + "type" : "integer" + }, + "date" : { + "format" : "date", + "type" : "string" + }, + "description" : { + "type" : "string" + }, + "referenceId" : { + "type" : "string" + }, + "status" : { + "type" : "string" + }, + "title" : { + "type" : "string" + }, + "type" : { + "type" : "string" + } + }, + "type" : "object" + }, "TokenDto" : { "properties" : { "accessToken" : { @@ -8484,6 +8550,53 @@ "tags" : [ "아이 관리" ] } }, + "/children/{childId}/timeline" : { + "get" : { + "description" : "접종·검진 권장 시기·지원금 신청 마감·신학기를 한 축에 모아 날짜순으로 준다. 놓친 항목(OVERDUE)은 구간 앞이라도 포함한다. 기본 12개월, 최대 36개월.", + "operationId" : "getTimeline", + "parameters" : [ { + "in" : "path", + "name" : "childId", + "required" : true, + "schema" : { + "format" : "int64", + "type" : "integer" + } + }, { + "description" : "조회 기간(개월). 기본 12, 최대 36", + "in" : "query", + "name" : "months", + "required" : false, + "schema" : { + "format" : "int32", + "type" : "integer" + } + }, { + "description" : "API 버전. 생략하면 현재 버전(1). 지원하지 않는 값이면 400", + "in" : "header", + "name" : "X-API-Version", + "required" : false, + "schema" : { + "default" : "1", + "type" : "string" + } + } ], + "responses" : { + "200" : { + "content" : { + "application/json" : { + "schema" : { + "$ref" : "#/components/schemas/ChildTimelineResponse" + } + } + }, + "description" : "OK" + } + }, + "summary" : "아이 할 일 타임라인", + "tags" : [ "아이 관리" ] + } + }, "/children/{childId}/vaccinations" : { "get" : { "description" : "표준 일정에 따른 접종 예정일과 완료 여부 반환", diff --git a/src/apis/child.ts b/src/apis/child.ts index 49695de..5206545 100644 --- a/src/apis/child.ts +++ b/src/apis/child.ts @@ -3,6 +3,8 @@ import { Child, ChildBody, childBodySchema, + ChildTimeline, + childTimelineSchema, childListSchema, childSchema, GrowthMetric, @@ -22,6 +24,21 @@ export const getMyChildren = async (): Promise => { return childListSchema.parse(res.data) } +/** + * GET /children/{childId}/timeline - 접종·검진·지원금 마감을 한 축에 모은 할 일 + * + * 기간을 주지 않으면 서버가 12개월로 잡는다(최대 36개월). + */ +export const getChildTimeline = async ( + childId: number, + months?: number, +): Promise => { + const res = await CareCode.get(`/children/${childId}/timeline`, { + params: months ? { months } : {}, + }) + return childTimelineSchema.parse(res.data) +} + // GET /children/overview - 모든 자녀의 접종·대기·다자녀 혜택을 한 번에 export const getSiblingOverview = async (): Promise => { const res = await CareCode.get('/children/overview') diff --git a/src/app/(without-tabs)/children/[childId]/page.tsx b/src/app/(without-tabs)/children/[childId]/page.tsx index ead7b27..155432f 100644 --- a/src/app/(without-tabs)/children/[childId]/page.tsx +++ b/src/app/(without-tabs)/children/[childId]/page.tsx @@ -3,6 +3,7 @@ import * as Tabs from '@radix-ui/react-tabs' import clsx from 'clsx' import { useParams, useRouter } from 'next/navigation' import { ReactElement, useState } from 'react' +import WarningIcon from '@/assets/icons/warning.svg' import AlertDialog from '@/components/common/AlertDialog' import AuthGuard from '@/components/common/AuthGuard' import Button from '@/components/common/Button' @@ -12,9 +13,11 @@ import ErrorView from '@/components/common/Error' import Layout from '@/components/common/Layout' import ToggleChip from '@/components/common/ToggleChip' import GrowthChart from '@/components/features/child/GrowthChart' +import TimelineItem from '@/components/features/child/TimelineItem' import VaccinationItem from '@/components/features/child/VaccinationItem' import { useChildDetail, + useChildTimeline, useCompleteVaccination, useDeleteChild, useGrowthChart, @@ -34,6 +37,12 @@ const ChildDetailPage = (): ReactElement => { const { data: child, isLoading, isError, refetch } = useChildDetail(childId) const { data: schedules = [], isLoading: isScheduleLoading } = useVaccinationSchedule(childId) + const { + data: timeline, + isLoading: isTimelineLoading, + isError: isTimelineError, + refetch: refetchTimeline, + } = useChildTimeline(childId) const { data: growthPoints = [], isLoading: isGrowthLoading } = useGrowthChart(childId, metric) const { mutate: completeVaccination } = useCompleteVaccination(childId) const { mutate: removeChild, isPending: isDeleting } = useDeleteChild() @@ -98,9 +107,10 @@ const ChildDetailPage = (): ReactElement => { - + {[ + { value: 'todo', label: '할 일' }, { value: 'vaccination', label: '예방접종' }, { value: 'growth', label: '성장 기록' }, ].map((tab) => ( @@ -117,6 +127,52 @@ const ChildDetailPage = (): ReactElement => { ))} + {/* 할 일 — 접종·검진·지원금 마감을 한 축에 모아서 */} + + {isTimelineError ? ( + refetchTimeline()} /> + ) : isTimelineLoading ? ( +
+ {[0, 1, 2, 3, 4].map((i) => ( +
+ ))} +
+ ) : !timeline?.items.length ? ( + + ) : ( + <> + {/* 놓친 일이 있으면 맨 위에서 한 번에 알린다. 목록에 섞이면 지나친다. */} + {timeline.overdueCount > 0 && ( +
+ + + 기한이 지난 일이 {timeline.overdueCount}건{' '} + 있어요. 아직 할 수 있는지 확인해 보세요. + +
+ )} + +

+ {formatDate(timeline.from)} ~ {formatDate(timeline.to)} · 예정{' '} + {timeline.upcomingCount}건 +

+ +
    + {timeline.items.map((item, index) => ( + + ))} +
+ +

+ 검진·신학기는 권장 시기 안내예요. 실제 일정은 기관마다 다를 수 있어요. +

+ + )} + + {/* 예방접종 */} {isScheduleLoading ? ( diff --git a/src/components/features/child/TimelineItem.tsx b/src/components/features/child/TimelineItem.tsx new file mode 100644 index 0000000..efbade8 --- /dev/null +++ b/src/components/features/child/TimelineItem.tsx @@ -0,0 +1,75 @@ +import clsx from 'clsx' +import { ReactElement } from 'react' +import Chip from '@/components/common/Chip' +import { + TIMELINE_TYPE_LABEL, + TimelineItem as TimelineItemData, + TimelineItemType, +} from '@/types/apis/child' +import { formatDate } from '@/utils/date' + +interface TimelineItemProps { + item: TimelineItemData +} + +/** 상태별 표시. 서버가 새 상태를 추가하면 회색 기본값으로 보여 주고 화면은 살려 둔다. */ +const STATUS_STYLE: Record = { + OVERDUE: { label: '기한 경과', color: 'red' }, + UPCOMING: { label: '예정', color: 'green' }, + DONE: { label: '완료', color: 'blue' }, + INFO: { label: '참고', color: 'white' }, +} + +const TimelineItem = ({ item }: TimelineItemProps): ReactElement => { + const status = STATUS_STYLE[item.status] + const typeLabel = TIMELINE_TYPE_LABEL[item.type as TimelineItemType] ?? item.type + const isOverdue = item.status === 'OVERDUE' + const isDone = item.status === 'DONE' + + return ( +
  • + {/* 날짜 열. 세로 축처럼 보이게 왼쪽에 고정 폭으로 둔다. */} +
    + + {formatDate(item.date, 'MM.dd')} + + {item.ageMonths != null && ( + {item.ageMonths}개월 + )} +
    + +
    +
    + + {item.title} + + + {typeLabel} + + {status && ( + + {status.label} + + )} +
    + {item.description && ( + + {item.description} + + )} +
    +
  • + ) +} + +export default TimelineItem diff --git a/src/components/features/child/__tests__/TimelineItem.test.tsx b/src/components/features/child/__tests__/TimelineItem.test.tsx new file mode 100644 index 0000000..ef18796 --- /dev/null +++ b/src/components/features/child/__tests__/TimelineItem.test.tsx @@ -0,0 +1,66 @@ +import { render, screen } from '@testing-library/react' +import { describe, expect, it } from 'vitest' +import TimelineItem from '@/components/features/child/TimelineItem' +import { TimelineItem as TimelineItemData } from '@/types/apis/child' + +const item = (overrides: Partial = {}): TimelineItemData => ({ + date: '2026-11-03', + type: 'VACCINATION', + status: 'UPCOMING', + title: 'B형간염 3차', + description: '권장 접종 시기입니다.', + referenceId: '12', + ageMonths: 13, + ...overrides, +}) + +describe('TimelineItem', () => { + it('날짜·월령·종류·상태를 함께 보여준다', () => { + render() + + expect(screen.getByText('11.03')).toBeInTheDocument() + expect(screen.getByText('13개월')).toBeInTheDocument() + expect(screen.getByText('B형간염 3차')).toBeInTheDocument() + expect(screen.getByText('예방접종')).toBeInTheDocument() + expect(screen.getByText('예정')).toBeInTheDocument() + }) + + it('기한이 지난 일은 경과로 표시한다 — 지난 일이라고 흐리게 두면 놓친 걸 모른다', () => { + render() + + expect(screen.getByText('기한 경과')).toBeInTheDocument() + }) + + it('완료한 일은 완료로 표시한다', () => { + render() + + expect(screen.getByText('완료')).toBeInTheDocument() + expect(screen.getByText('건강검진')).toBeInTheDocument() + }) + + it('참고 항목(신학기)은 참고로 표시한다', () => { + render( + , + ) + + expect(screen.getByText('참고')).toBeInTheDocument() + expect(screen.getByText('신학기')).toBeInTheDocument() + expect(screen.getByText(/시설마다 다르므로/)).toBeInTheDocument() + }) + + it('서버가 모르는 종류·상태를 보내도 원본 값을 보여주고 화면은 살아 있다', () => { + render() + + expect(screen.getByText('SOMETHING_NEW')).toBeInTheDocument() + expect(screen.getByText('B형간염 3차')).toBeInTheDocument() + }) +}) diff --git a/src/queries/child.ts b/src/queries/child.ts index c3a36bb..1d3d669 100644 --- a/src/queries/child.ts +++ b/src/queries/child.ts @@ -10,6 +10,7 @@ import { getAccessToken } from '@/apis/auth' import { deleteChild, getChildById, + getChildTimeline, getGrowthChart, getLatestGrowth, getMyChildren, @@ -23,6 +24,7 @@ import { import { Child, ChildBody, + ChildTimeline, GrowthMetric, GrowthPoint, SiblingOverview, @@ -45,6 +47,11 @@ export const childQueries = createQueryKeys('child', { queryFn: () => getChildById(childId), }), + timeline: (childId: number, months?: number) => ({ + queryKey: ['timeline', childId, months ?? 12], + queryFn: () => getChildTimeline(childId, months), + }), + vaccinations: (childId: number) => ({ queryKey: ['vaccinations', childId], queryFn: () => getVaccinationSchedule(childId), @@ -66,6 +73,15 @@ export const childQueries = createQueryKeys('child', { }), }) +export const useChildTimeline = ( + childId: number, + months?: number, +): UseQueryResult => + useQuery({ + ...childQueries.timeline(childId, months), + enabled: !!getAccessToken() && Number.isFinite(childId), + }) + export const useMyChildren = (): UseQueryResult => useQuery({ ...childQueries.list(), enabled: !!getAccessToken() }) diff --git a/src/types/apis/__tests__/contracts-alignment.test.ts b/src/types/apis/__tests__/contracts-alignment.test.ts index cb8b31e..e5b1cee 100644 --- a/src/types/apis/__tests__/contracts-alignment.test.ts +++ b/src/types/apis/__tests__/contracts-alignment.test.ts @@ -3,6 +3,7 @@ */ import { describe, expect, it } from 'vitest' import { getKakaoAuthUrlResponseSchema, kakaoRegistrationResponseSchema } from '@/types/apis/auth' +import { childTimelineSchema } from '@/types/apis/child' import { postCommentSchema } from '@/types/apis/community' import { policySearchResponseSchema } from '@/types/apis/policy' @@ -116,3 +117,53 @@ describe('policySearchResponseSchema (POST /policies/search)', () => { expect(parsed.policies[0].id).toBe(3) }) }) + +describe('childTimelineSchema (GET /children/{childId}/timeline)', () => { + it('서버가 보내는 항목을 그대로 읽는다', () => { + const parsed = childTimelineSchema.parse({ + childId: 7, + childName: '아이', + birthDate: '2025-08-20', + from: '2026-09-25', + to: '2027-09-25', + overdueCount: 2, + upcomingCount: 5, + items: [ + { + date: '2026-10-01', + type: 'VACCINATION', + status: 'OVERDUE', + title: 'B형간염 3차', + description: '권장 시기가 지났습니다.', + referenceId: '31', + ageMonths: 13, + }, + { + date: '2027-03-01', + type: 'NEW_TERM', + status: 'INFO', + title: '3월 신학기', + description: '신청 일정은 시설마다 다르므로 관심 시설에 직접 확인하세요.', + ageMonths: 18, + }, + ], + }) + + expect(parsed.items).toHaveLength(2) + expect(parsed.items[0].status).toBe('OVERDUE') + expect(parsed.items[1].referenceId).toBeUndefined() + }) + + it('서버가 새 종류·상태를 추가해도 파싱은 통과한다 — 화면 전체가 죽지 않게', () => { + const parsed = childTimelineSchema.parse({ + childId: 7, + from: '2026-09-25', + to: '2027-09-25', + overdueCount: 0, + upcomingCount: 1, + items: [{ date: '2026-12-01', type: 'DENTAL_CHECK', status: 'PLANNED', title: '치과 검진' }], + }) + + expect(parsed.items[0].type).toBe('DENTAL_CHECK') + }) +}) diff --git a/src/types/apis/child.ts b/src/types/apis/child.ts index f3aa914..9a2f968 100644 --- a/src/types/apis/child.ts +++ b/src/types/apis/child.ts @@ -24,6 +24,50 @@ export const childBodySchema = z.object({ }) export type ChildBody = z.infer +// ==================== 할 일 타임라인 ==================== + +export const TimelineItemType = ['VACCINATION', 'CHECKUP', 'POLICY_DEADLINE', 'NEW_TERM'] as const +export type TimelineItemType = (typeof TimelineItemType)[number] + +export const TIMELINE_TYPE_LABEL: Record = { + VACCINATION: '예방접종', + CHECKUP: '건강검진', + POLICY_DEADLINE: '지원금 신청', + NEW_TERM: '신학기', +} + +export const TimelineItemStatus = ['OVERDUE', 'UPCOMING', 'DONE', 'INFO'] as const +export type TimelineItemStatus = (typeof TimelineItemStatus)[number] + +/** + * 서버 ChildTimelineResponse.TimelineItem 대응. + * + * type·status 는 서버가 값을 추가할 수 있으므로 문자열로 받고 표시할 때 매핑한다 + * (enum 으로 받으면 서버가 항목을 늘릴 때 화면 전체가 파싱 실패로 죽는다). + */ +export const timelineItemSchema = z.object({ + date: z.string(), // yyyy-MM-dd + type: z.string(), + status: z.string(), + title: z.string(), + description: z.string().nullish(), + referenceId: z.string().nullish(), + ageMonths: z.number().nullish(), +}) +export type TimelineItem = z.infer + +export const childTimelineSchema = z.object({ + childId: z.number(), + childName: z.string().nullish(), + birthDate: z.string().nullish(), + from: z.string(), + to: z.string(), + overdueCount: z.number(), + upcomingCount: z.number(), + items: z.array(timelineItemSchema), +}) +export type ChildTimeline = z.infer + // ==================== 예방접종 ==================== export const VaccinationStatus = ['SCHEDULED', 'COMPLETED', 'SKIPPED'] as const