Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 113 additions & 0 deletions openapi/openapi.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" : {
Expand Down Expand Up @@ -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" : {
Expand Down Expand Up @@ -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" : "표준 일정에 따른 접종 예정일과 완료 여부 반환",
Expand Down
17 changes: 17 additions & 0 deletions src/apis/child.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import {
Child,
ChildBody,
childBodySchema,
ChildTimeline,
childTimelineSchema,
childListSchema,
childSchema,
GrowthMetric,
Expand All @@ -22,6 +24,21 @@ export const getMyChildren = async (): Promise<Child[]> => {
return childListSchema.parse(res.data)
}

/**
* GET /children/{childId}/timeline - 접종·검진·지원금 마감을 한 축에 모은 할 일
*
* 기간을 주지 않으면 서버가 12개월로 잡는다(최대 36개월).
*/
export const getChildTimeline = async (
childId: number,
months?: number,
): Promise<ChildTimeline> => {
const res = await CareCode.get(`/children/${childId}/timeline`, {
params: months ? { months } : {},
})
return childTimelineSchema.parse(res.data)
}

// GET /children/overview - 모든 자녀의 접종·대기·다자녀 혜택을 한 번에
export const getSiblingOverview = async (): Promise<SiblingOverview> => {
const res = await CareCode.get('/children/overview')
Expand Down
58 changes: 57 additions & 1 deletion src/app/(without-tabs)/children/[childId]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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,
Expand All @@ -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()
Expand Down Expand Up @@ -98,9 +107,10 @@ const ChildDetailPage = (): ReactElement => {
</div>
</section>

<Tabs.Root defaultValue="vaccination" className="flex grow flex-col">
<Tabs.Root defaultValue="todo" className="flex grow flex-col">
<Tabs.List className="flex border-b border-gray-200 bg-white">
{[
{ value: 'todo', label: '할 일' },
{ value: 'vaccination', label: '예방접종' },
{ value: 'growth', label: '성장 기록' },
].map((tab) => (
Expand All @@ -117,6 +127,52 @@ const ChildDetailPage = (): ReactElement => {
))}
</Tabs.List>

{/* 할 일 — 접종·검진·지원금 마감을 한 축에 모아서 */}
<Tabs.Content value="todo" className="flex flex-col py-4">
{isTimelineError ? (
<ErrorView content="할 일을 불러오지 못했어요." onRetry={() => refetchTimeline()} />
) : isTimelineLoading ? (
<div className="flex flex-col gap-2 px-4.5">
{[0, 1, 2, 3, 4].map((i) => (
<div key={i} className="h-16 animate-pulse rounded bg-gray-200" />
))}
</div>
) : !timeline?.items.length ? (
<EmptyState
title="앞으로 1년 동안 챙길 일이 없어요"
description={'생년월일이 등록되면 접종·검진 시기가\n자동으로 채워져요.'}
/>
) : (
<>
{/* 놓친 일이 있으면 맨 위에서 한 번에 알린다. 목록에 섞이면 지나친다. */}
{timeline.overdueCount > 0 && (
<div className="mx-4.5 mb-3 flex items-center gap-2 rounded-lg bg-red-50 px-3.5 py-3">
<WarningIcon className="size-5 shrink-0" />
<span className="text-b2-regular text-gray-800">
기한이 지난 일이 <b className="text-b2-semibold">{timeline.overdueCount}건</b>{' '}
있어요. 아직 할 수 있는지 확인해 보세요.
</span>
</div>
)}

<p className="text-c1-regular px-4.5 pb-2 text-gray-500">
{formatDate(timeline.from)} ~ {formatDate(timeline.to)} · 예정{' '}
{timeline.upcomingCount}건
</p>

<ul className="bg-white">
{timeline.items.map((item, index) => (
<TimelineItem key={`${item.type}-${item.referenceId ?? index}`} item={item} />
))}
</ul>

<p className="text-c1-regular px-4.5 pt-3 text-gray-500">
검진·신학기는 권장 시기 안내예요. 실제 일정은 기관마다 다를 수 있어요.
</p>
</>
)}
</Tabs.Content>

{/* 예방접종 */}
<Tabs.Content value="vaccination" className="flex flex-col gap-4 py-4">
{isScheduleLoading ? (
Expand Down
75 changes: 75 additions & 0 deletions src/components/features/child/TimelineItem.tsx
Original file line number Diff line number Diff line change
@@ -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<string, { label: string; color: 'red' | 'green' | 'blue' | 'white' }> = {
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 (
<li
className={clsx(
'flex gap-3 border-b border-gray-200 px-4.5 py-3.5 last:border-b-0',
isDone && 'bg-gray-50',
)}
>
{/* 날짜 열. 세로 축처럼 보이게 왼쪽에 고정 폭으로 둔다. */}
<div className="flex w-14 shrink-0 flex-col items-center pt-0.5">
<span className={clsx('text-b2-semibold', isOverdue ? 'text-red' : 'text-gray-700')}>
{formatDate(item.date, 'MM.dd')}
</span>
{item.ageMonths != null && (
<span className="text-c1-regular text-gray-500">{item.ageMonths}개월</span>
)}
</div>

<div className="flex min-w-0 flex-col gap-1">
<div className="flex flex-wrap items-center gap-1.5">
<span
className={clsx(
'text-b1-semibold',
isDone ? 'text-gray-500 line-through' : 'text-gray-800',
)}
>
{item.title}
</span>
<Chip color="transparent" size="sm">
{typeLabel}
</Chip>
{status && (
<Chip color={status.color} size="sm">
{status.label}
</Chip>
)}
</div>
{item.description && (
<span className="text-b2-regular whitespace-pre-line text-gray-600">
{item.description}
</span>
)}
</div>
</li>
)
}

export default TimelineItem
Loading
Loading