diff --git a/assets/images/income-card.svg b/assets/images/income-card.svg
new file mode 100644
index 0000000..8033552
--- /dev/null
+++ b/assets/images/income-card.svg
@@ -0,0 +1,26 @@
+
\ No newline at end of file
diff --git a/assets/images/income-gradient.svg b/assets/images/income-gradient.svg
new file mode 100644
index 0000000..332a7ce
--- /dev/null
+++ b/assets/images/income-gradient.svg
@@ -0,0 +1,19 @@
+
\ No newline at end of file
diff --git a/assets/images/navigation/home-active.svg b/assets/images/navigation/home-active.svg
new file mode 100644
index 0000000..80de17a
--- /dev/null
+++ b/assets/images/navigation/home-active.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/images/navigation/home.svg b/assets/images/navigation/home.svg
new file mode 100644
index 0000000..3035cde
--- /dev/null
+++ b/assets/images/navigation/home.svg
@@ -0,0 +1,3 @@
+
diff --git a/assets/images/navigation/income-active.svg b/assets/images/navigation/income-active.svg
new file mode 100644
index 0000000..a15b09f
--- /dev/null
+++ b/assets/images/navigation/income-active.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/images/navigation/income.svg b/assets/images/navigation/income.svg
new file mode 100644
index 0000000..50f47dd
--- /dev/null
+++ b/assets/images/navigation/income.svg
@@ -0,0 +1,4 @@
+
diff --git a/assets/images/navigation/reports-active.svg b/assets/images/navigation/reports-active.svg
new file mode 100644
index 0000000..11cb463
--- /dev/null
+++ b/assets/images/navigation/reports-active.svg
@@ -0,0 +1,7 @@
+
diff --git a/assets/images/navigation/reports.svg b/assets/images/navigation/reports.svg
new file mode 100644
index 0000000..a5545cf
--- /dev/null
+++ b/assets/images/navigation/reports.svg
@@ -0,0 +1,7 @@
+
diff --git a/assets/images/navigation/schedule-active.svg b/assets/images/navigation/schedule-active.svg
new file mode 100644
index 0000000..e61ff8d
--- /dev/null
+++ b/assets/images/navigation/schedule-active.svg
@@ -0,0 +1,6 @@
+
diff --git a/assets/images/navigation/schedule.svg b/assets/images/navigation/schedule.svg
new file mode 100644
index 0000000..799d7fa
--- /dev/null
+++ b/assets/images/navigation/schedule.svg
@@ -0,0 +1,6 @@
+
diff --git a/assets/images/navigation/students-active.svg b/assets/images/navigation/students-active.svg
new file mode 100644
index 0000000..3a81f7d
--- /dev/null
+++ b/assets/images/navigation/students-active.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/images/navigation/students.svg b/assets/images/navigation/students.svg
new file mode 100644
index 0000000..c7683e2
--- /dev/null
+++ b/assets/images/navigation/students.svg
@@ -0,0 +1,5 @@
+
diff --git a/assets/images/push-banner-icon.svg b/assets/images/push-banner-icon.svg
new file mode 100644
index 0000000..5cd1c69
--- /dev/null
+++ b/assets/images/push-banner-icon.svg
@@ -0,0 +1,41 @@
+
\ No newline at end of file
diff --git a/docs/income-status.md b/docs/income-status.md
new file mode 100644
index 0000000..9413f90
--- /dev/null
+++ b/docs/income-status.md
@@ -0,0 +1,27 @@
+# T-15 수입·입금 현황
+
+## 구현 범위
+
+- 수입 탭의 PlaceholderScreen을 실제 IncomeScreen으로 교체
+- 월 교육비·입금·미수금 요약, 학생별 청구 회차·금액·입금 대기/일부 입금/입금완료 표시
+- 학부모의 입금 신고와 실제 받은 금액을 구분. 신고만으로 입금완료로 전환하지 않음
+- 카드 스켈레톤 로딩, 빈 목록, 오류 재시도 표시
+- 원본 SVG에서 요약 그라데이션과 일러스트만 추출하고 숫자·학생 정보는 동적으로 렌더링
+- 기존 하단 탭 및 인증용 메모리 상태를 재사용. 정산 리포트 및 다른 탭 화면은 수정하지 않음
+
+## API 상태
+
+API는 미확정입니다. `IncomeState`/`IncomeData`는 화면 모델이며 실제 계약이 아닙니다. 개발 환경에서만 확인용 데이터를 사용합니다. production은 실제 조회 연동 전까지 준비 중 안내를 표시하고 임의의 금액을 노출하지 않습니다.
+
+월 집계와 목록은 별도로 전달할 수 있게 구분했습니다. 정상 예시는 청구 1,200,000원, 입금 400,000원, 미수금 800,000원입니다. 빈 화면 시안에도 같은 요약 금액이 남아 있어 빈 목록 미리보기는 이를 유지합니다. 실제 API 연동 시에는 조회 범위와 집계·목록 정합성을 확인해야 합니다.
+
+부모 입금 알림·예약 알림 전송, 은행 계좌 조회, 입금 확인·금액 변경은 연결하지 않았습니다. 알림 문구와 날짜는 확인용 데이터이며 실제 예약·전송을 뜻하지 않습니다.
+
+## 확인 경로
+
+- `/dev/income`: 실제 수입 탭에서 정상 상태 확인
+- `/dev/income?state=loading`: 고정 스켈레톤
+- `/dev/income?state=empty`: 빈 목록
+- `/dev/income?state=error`: 조회 실패 및 재시도
+
+개발용 진입점은 프로필이 없으면 메모리에 미리보기 프로필을 설정해 기존 탭으로 이동합니다. production에서는 이 진입을 허용하지 않습니다. 실제 수입 탭은 `/income`이며 다른 탭의 이동 구조·공통 탭 아이콘 구현은 유지합니다.
diff --git a/docs/navigation-icons.md b/docs/navigation-icons.md
new file mode 100644
index 0000000..7f207d7
--- /dev/null
+++ b/docs/navigation-icons.md
@@ -0,0 +1,9 @@
+# 하단 탭 아이콘
+
+홈·일정·학생·리포트·수입 SVG를 MainTabBar에 연결했습니다. icNav.svg는 교육비/통화 모양으로 수입 탭에 매핑했습니다.
+
+- 활성: 아이콘·글자 `#219BFF`
+- 비활성: 아이콘 `#B5B9C0`, 글자 디자인 토큰의 weak 색상
+- 32px 아이콘 프레임, 기존 탭 이동·다시 누르기 이벤트 유지
+- SVG의 회색 면만 파란색으로 변경한 활성 에셋을 사용해 시계·리포트·통화 내부 흰색 디테일 보존
+- 상단 알림 배너와 하단 탭은 별도 기능입니다. 실제 탭 화면에는 표시되며 전체 화면/개발 미리보기 경로에는 자동으로 하단 탭을 추가하지 않습니다.
diff --git a/docs/push-banner.md b/docs/push-banner.md
new file mode 100644
index 0000000..1ded3ed
--- /dev/null
+++ b/docs/push-banner.md
@@ -0,0 +1,41 @@
+# 공통 PushBanner
+
+`pushBanner.svg`, `pushBanner 표시 방식.svg`를 기준으로 앱 내부 상단 배너를 구현했습니다. OS 상태바는 그리지 않고 Safe Area 아래에 배너를 표시합니다. 기본 좌우 여백 10, 너비 최대 370, 최소 높이 82, 원본 아이콘·그림자를 적용했습니다.
+
+## 구성
+
+- `src/components/PushBanner.tsx`: 브랜드·시각·제목·본문과 탭/접근성 닫기 동작
+- `src/features/notifications/PushBannerProvider.tsx`: 전역 표시, 자동 닫기, 순차 큐
+- 루트 레이아웃에서 Provider 적용. 기존 화면 UI와 무관하게 `usePushBanner()` 호출 가능
+
+```tsx
+const { show, dismiss } = usePushBanner();
+const id = show({
+ id: 'lesson-ended-123',
+ title: '윤서아 학생 수업이 끝났어요',
+ message: '잊기 전에 8회차를 기록해 주세요',
+ timeLabel: '지금',
+ durationMs: 5000,
+ onPress: () => {
+ /* 확정된 화면 경로로 이동 */
+ },
+});
+dismiss(id);
+```
+
+- 기본 표시 5초, `durationMs: 0`이면 유지. 시안에는 시간이 정해져 있지 않아 변경 가능하도록 구성
+- 배너 탭 시 닫은 후 콜백 실행. 콜백이 없는 알림은 탭 시 닫기만 수행
+- 여러 알림은 동시에 겹치지 않고 순서대로 표시. 동일 ID는 기존 항목 갱신. 최대 5개까지 보관하며 초과 신규 항목은 추가하지 않음
+- 앱이 백그라운드이면 배너를 숨기고 타이머 정지, 복귀 시 현재 배너의 표시 시간을 다시 시작
+- 자동 닫기와 AppState 구독은 Provider 종료 시 정리
+- 원본처럼 닫기 X 버튼은 표시하지 않으며 접근성 닫기 동작을 제공
+
+## 미리보기
+
+`/dev/push-banner`에서 최초 시안 배너를 유지한 상태로 확인합니다. 배너 탭, 5초 자동 닫기, 순차 알림, 긴 문구 및 닫기를 확인할 수 있습니다. 개발 전용이며 production에서는 홈으로 이동합니다.
+
+## API 연결 전 범위
+
+푸시 API·FCM·APNs·Web Push·알림 권한·백그라운드/잠금화면 수신은 연결하지 않았습니다. 실제 수업 종료 시 자동으로 알림을 생성하지 않습니다. API/수신 이벤트 확정 후 안전하게 변환한 문자열과 화면 이동 콜백을 `show()`에 전달해야 합니다. API에서 받은 임의 URL을 직접 실행하지 않습니다.
+
+이 배너는 일반 앱 화면 위의 인앱 오버레이입니다. OS 푸시 시스템을 흉내 내거나 네이티브 Modal 위 노출을 보장하는 구현이 아닙니다.
diff --git a/docs/screenshots/income-navigation.png b/docs/screenshots/income-navigation.png
new file mode 100644
index 0000000..28fc797
Binary files /dev/null and b/docs/screenshots/income-navigation.png differ
diff --git a/docs/screenshots/push-banner.png b/docs/screenshots/push-banner.png
new file mode 100644
index 0000000..99d1fc3
Binary files /dev/null and b/docs/screenshots/push-banner.png differ
diff --git a/src/app/(tabs)/income.tsx b/src/app/(tabs)/income.tsx
index 5a43d35..76fba3a 100644
--- a/src/app/(tabs)/income.tsx
+++ b/src/app/(tabs)/income.tsx
@@ -1,4 +1,17 @@
-import PlaceholderScreen from '@/features/main/PlaceholderScreen';
+import { useState } from 'react';
+import { Tabs, useLocalSearchParams } from 'expo-router';
+import { IncomeScreen } from '@/features/income/IncomeScreen';
+import { incomePreviewState } from '@/features/income/previewData';
export default function Screen() {
- return ;
+ const { preview } = useLocalSearchParams<{ preview?: string }>();
+ const [retried, setRetried] = useState(false);
+ const state = __DEV__
+ ? incomePreviewState(retried ? 'normal' : preview)
+ : { status: 'unavailable' as const };
+ return (
+ <>
+
+ setRetried(true)} />
+ >
+ );
}
diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx
index 5d16365..1df1185 100644
--- a/src/app/_layout.tsx
+++ b/src/app/_layout.tsx
@@ -2,12 +2,17 @@ import { Stack } from 'expo-router';
import { StatusBar } from 'expo-status-bar';
import { SafeAreaProvider } from 'react-native-safe-area-context';
import { OnboardingProvider } from '@/features/onboarding/OnboardingProvider';
+import { PushBannerProvider } from '@/features/notifications/PushBannerProvider';
export default function RootLayout() {
return (
-
-
+
+
+
+
);
diff --git a/src/app/dev/income.tsx b/src/app/dev/income.tsx
new file mode 100644
index 0000000..f43533e
--- /dev/null
+++ b/src/app/dev/income.tsx
@@ -0,0 +1,13 @@
+import { useEffect } from 'react';
+import { Redirect, useLocalSearchParams } from 'expo-router';
+import { useOnboarding } from '@/features/onboarding/OnboardingProvider';
+export default function IncomePreview() {
+ const { state } = useLocalSearchParams<{ state?: string }>();
+ const { profile, setProfile } = useOnboarding();
+ useEffect(() => {
+ if (__DEV__ && !profile.name) setProfile({ name: '미리보기', subjects: [] });
+ }, [profile.name, setProfile]);
+ if (!__DEV__) return ;
+ if (!profile.name) return null;
+ return ;
+}
diff --git a/src/app/dev/push-banner.tsx b/src/app/dev/push-banner.tsx
new file mode 100644
index 0000000..f793eef
--- /dev/null
+++ b/src/app/dev/push-banner.tsx
@@ -0,0 +1,114 @@
+import { useCallback, useEffect, useRef, useState } from 'react';
+import { Redirect } from 'expo-router';
+import { ScrollView, StyleSheet, Text, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { ActionButton } from '@/components/ActionButton';
+import { usePushBanner } from '@/features/notifications/PushBannerProvider';
+import { semantic as c } from '@/styles/colors';
+import { typography as t } from '@/styles/typography';
+export default function BannerPreview() {
+ const { show, dismiss } = usePushBanner();
+ const ids = useRef([]);
+ const [opened, setOpened] = useState(false);
+ const sample = useCallback(
+ (durationMs = 0) => {
+ const id = show({
+ title: '윤서아 학생 수업이 끝났어요',
+ message: '잊기 전에 8회차를 기록해 주세요',
+ durationMs,
+ onPress: () => setOpened(true),
+ });
+ ids.current.push(id);
+ },
+ [show],
+ );
+ useEffect(() => {
+ if (__DEV__) sample();
+ const ownIds = ids.current;
+ return () => {
+ ownIds.forEach((id) => dismiss(id));
+ };
+ }, [sample, dismiss]);
+ if (!__DEV__) return ;
+ function clear() {
+ ids.current.forEach((id) => dismiss(id));
+ ids.current.length = 0;
+ }
+ return (
+
+
+
+ 배너 미리보기
+
+
+ 화면 위쪽에 알림이 표시됩니다. 배너를 누르면 닫히고 확인 동작을 실행해요.
+
+ {opened && (
+
+ 배너를 눌렀어요. 실제 회차 기록 이동은 연결 전입니다.
+
+ )}
+
+ {
+ clear();
+ sample();
+ }}
+ />
+ {
+ clear();
+ sample(5000);
+ }}
+ />
+ {
+ clear();
+ sample(2000);
+ const id = show({
+ title: '두 번째 알림',
+ message: '여러 알림은 겹치지 않고 차례로 표시돼요.',
+ durationMs: 0,
+ });
+ ids.current.push(id);
+ }}
+ />
+ {
+ clear();
+ const id = show({
+ title:
+ '학생의 수업 기록과 다음 숙제를 확인해 주세요. 긴 제목도 화면 너비에 맞춰 표시됩니다.',
+ message: '긴 본문은 두 줄까지 표시하고 나머지는 줄임표로 표시해요.',
+ durationMs: 0,
+ });
+ ids.current.push(id);
+ }}
+ />
+
+
+
+
+ );
+}
+const s = StyleSheet.create({
+ safe: { flex: 1, alignItems: 'center', backgroundColor: c.surface.input },
+ content: {
+ width: '100%',
+ maxWidth: 390,
+ paddingHorizontal: 24,
+ paddingTop: 180,
+ paddingBottom: 40,
+ },
+ heading: { ...t.heading[1].semiBold, color: c.text.default.strong, marginBottom: 12 },
+ body: { ...t.body[4].regular, color: c.text.default.weak, marginBottom: 24 },
+ result: { ...t.body[4].medium, color: c.text.brand.assistive, marginBottom: 16 },
+ buttons: { gap: 12 },
+});
diff --git a/src/components/PushBanner.tsx b/src/components/PushBanner.tsx
new file mode 100644
index 0000000..3c5ae9a
--- /dev/null
+++ b/src/components/PushBanner.tsx
@@ -0,0 +1,84 @@
+import { Image } from 'expo-image';
+import { Pressable, StyleSheet, Text, View } from 'react-native';
+import { semantic as c } from '@/styles/colors';
+import { typography as t } from '@/styles/typography';
+import { radius, padding, gap } from '@/styles/measurement';
+import icon from '../../assets/images/push-banner-icon.svg';
+export type PushBannerProps = {
+ title: string;
+ message: string;
+ timeLabel?: string;
+ onPress: () => void;
+ onDismiss: () => void;
+};
+/** 앱 내부 배너입니다. OS 푸시 권한이나 실제 알림 수신 기능을 포함하지 않습니다. */
+export function PushBanner({
+ title,
+ message,
+ timeLabel = '지금',
+ onPress,
+ onDismiss,
+}: PushBannerProps) {
+ return (
+
+ {
+ if (event.nativeEvent.actionName === 'dismiss') onDismiss();
+ }}
+ onAccessibilityEscape={onDismiss}
+ onPress={onPress}
+ style={({ pressed }) => [s.card, pressed && s.pressed]}
+ >
+
+
+
+ SUDAM
+ {timeLabel}
+
+
+ {title}
+
+
+ {message}
+
+
+
+
+ );
+}
+const s = StyleSheet.create({
+ shadow: {
+ borderRadius: radius.large,
+ backgroundColor: c.surface.subtle,
+ boxShadow: '0px 4px 8px rgba(0, 0, 0, 0.18)',
+ elevation: 6,
+ },
+ card: {
+ minHeight: 82,
+ borderRadius: radius.large,
+ paddingHorizontal: 14,
+ paddingVertical: padding[12],
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: 10,
+ backgroundColor: c.surface.subtle,
+ },
+ pressed: { opacity: 0.85 },
+ icon: { width: 38, height: 38 },
+ content: { flex: 1, minWidth: 0 },
+ meta: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ marginBottom: 2,
+ gap: gap[8],
+ },
+ brand: { ...t.caption[1].semiBold, color: c.text.default.strong },
+ time: { ...t.caption[1].regular, color: c.text.default.strong },
+ title: { ...t.body[3].semiBold, color: c.text.default.normal },
+ message: { ...t.caption[1].regular, color: c.text.default.normal },
+});
diff --git a/src/features/income/IncomeScreen.tsx b/src/features/income/IncomeScreen.tsx
new file mode 100644
index 0000000..6611d67
--- /dev/null
+++ b/src/features/income/IncomeScreen.tsx
@@ -0,0 +1,132 @@
+import { Image } from 'expo-image';
+import { ScrollView, Text, View } from 'react-native';
+import { SafeAreaView } from 'react-native-safe-area-context';
+import { ActionButton } from '@/components/ActionButton';
+import { s } from './styles';
+import { paymentStatus, won } from './types';
+import type { IncomeInvoice, IncomeState } from './types';
+import gradient from '../../../assets/images/income-gradient.svg';
+import card from '../../../assets/images/income-card.svg';
+
+function InvoiceCard({ invoice }: { invoice: IncomeInvoice }) {
+ const status = paymentStatus(invoice);
+ const paid = status === '입금완료';
+ const note =
+ invoice.receivedAmount > 0 && !paid
+ ? `받은 금액 ${won(invoice.receivedAmount)}`
+ : !paid && invoice.parentReportedAt
+ ? `학부모님이 [입금했어요]를 눌렀어요 · ${invoice.parentReportedAt}`
+ : !paid
+ ? invoice.reminderText
+ : undefined;
+ return (
+
+
+ {invoice.studentName}
+
+ {status}
+
+
+
+ 다음 {invoice.sessionCount}회차
+ {won(invoice.billedAmount)}
+
+ {note && (
+
+ {note}
+
+ )}
+
+ );
+}
+export function IncomeScreen({ state, onRetry }: { state: IncomeState; onRetry?: () => void }) {
+ return (
+
+
+
+
+ 수입
+
+
+
+ {state.status === 'loading' ? (
+
+
+
+
+
+ {[0, 1, 2, 3].map((i) => (
+
+ ))}
+
+
+ ) : state.status === 'ready' ? (
+ <>
+
+
+
+ 이번달 교육비
+ {won(state.data.summary.expectedAmount)}
+
+
+
+ 입금 {won(state.data.summary.receivedAmount)}
+
+
+ 미수금 {won(state.data.summary.outstandingAmount)}
+
+
+
+ {state.data.invoices.length > 0 && (
+
+ ⓘ
+
+ 학부모님이 [입금했어요]를 누르면 알려 드려요. 통장을 확인하고 체크해 주세요.
+
+
+ )}
+
+
+ 학생별
+
+ {state.data.invoices.length === 0 ? (
+
+ 이번달 청구가 아직 없어요
+
+ ) : (
+
+ {state.data.invoices.map((invoice) => (
+
+ ))}
+
+ )}
+
+ >
+ ) : (
+
+
+ {state.status === 'error'
+ ? state.message
+ : '수입 정보를 준비 중이에요. 연동 후 입금 현황을 확인할 수 있어요.'}
+
+ {state.status === 'error' && onRetry && (
+
+ )}
+
+ )}
+
+
+
+ );
+}
diff --git a/src/features/income/previewData.ts b/src/features/income/previewData.ts
new file mode 100644
index 0000000..8efbe25
--- /dev/null
+++ b/src/features/income/previewData.ts
@@ -0,0 +1,45 @@
+import type { IncomeData, IncomeState } from './types';
+export const previewIncome: IncomeData = {
+ summary: { expectedAmount: 1200000, receivedAmount: 400000, outstandingAmount: 800000 },
+ invoices: [
+ {
+ id: 'income-1',
+ studentName: '박지후',
+ sessionCount: 8,
+ billedAmount: 360000,
+ receivedAmount: 0,
+ parentReportedAt: '9/15',
+ },
+ {
+ id: 'income-2',
+ studentName: '윤서아',
+ sessionCount: 8,
+ billedAmount: 360000,
+ receivedAmount: 0,
+ reminderText: '안 들어오면 다음 수업 전날 오전 9시에 선생님께 알려 드려요',
+ },
+ {
+ id: 'income-3',
+ studentName: '이도윤',
+ sessionCount: 4,
+ billedAmount: 200000,
+ receivedAmount: 120000,
+ },
+ {
+ id: 'income-4',
+ studentName: '김하린',
+ sessionCount: 8,
+ billedAmount: 280000,
+ receivedAmount: 280000,
+ },
+ ],
+};
+export function incomePreviewState(value?: string): IncomeState {
+ if (value === 'loading') return { status: 'loading' };
+ if (value === 'error')
+ return { status: 'error', message: '수입 정보를 불러오지 못했어요. 다시 시도해 주세요.' };
+ // 빈 화면 SVG에도 월 요약 금액이 남아 있어 시각 확인용으로 동일하게 유지합니다.
+ if (value === 'empty')
+ return { status: 'ready', data: { summary: { ...previewIncome.summary }, invoices: [] } };
+ return { status: 'ready', data: previewIncome };
+}
diff --git a/src/features/income/styles.ts b/src/features/income/styles.ts
new file mode 100644
index 0000000..4846c1a
--- /dev/null
+++ b/src/features/income/styles.ts
@@ -0,0 +1,101 @@
+import { StyleSheet, Platform } from 'react-native';
+import { primitive, semantic as c } from '@/styles/colors';
+import { typography as t } from '@/styles/typography';
+import { padding, radius, gap, grid } from '@/styles/measurement';
+export const s = StyleSheet.create({
+ safe: { flex: 1, alignItems: 'center', backgroundColor: c.surface.input },
+ phone: { flex: 1, width: '100%', maxWidth: 390, paddingTop: Platform.OS === 'web' ? 44 : 0 },
+ header: { height: 67, justifyContent: 'center', paddingHorizontal: grid.margin },
+ title: { ...t.heading[1].semiBold, color: c.text.default.strong },
+ scroll: { flex: 1 },
+ content: { paddingHorizontal: grid.margin, paddingBottom: padding[16] },
+ summary: {
+ height: 128,
+ borderRadius: radius.large,
+ overflow: 'hidden',
+ padding: padding[16],
+ justifyContent: 'space-between',
+ backgroundColor: c.action.default,
+ },
+ gradient: { position: 'absolute', top: 0, left: 0, width: '100%', height: '100%' },
+ summaryLabel: { ...t.caption[1].semiBold, color: c.text.default.inverse, marginTop: gap[4] },
+ amount: {
+ ...t.heading[1].bold,
+ color: c.text.default.inverse,
+ marginTop: gap[4],
+ paddingRight: 60,
+ },
+ totals: {
+ flexDirection: 'row',
+ flexWrap: 'wrap',
+ gap: gap[8],
+ alignItems: 'center',
+ paddingBottom: gap[4],
+ },
+ totalText: { ...t.body[5].semiBold, color: c.text.default.inverse },
+ separator: { width: 1, height: 12, backgroundColor: c.border.default },
+ illustration: { position: 'absolute', right: padding[16], top: 32, width: 64, height: 80 },
+ info: {
+ marginTop: gap[20],
+ minHeight: 48,
+ borderRadius: radius.small,
+ borderWidth: 0.5,
+ borderColor: c.border.focused,
+ backgroundColor: c.surface.brand,
+ padding: padding[8],
+ flexDirection: 'row',
+ alignItems: 'center',
+ gap: gap[8],
+ },
+ infoIcon: { fontSize: 14, color: c.text.brand.assistive },
+ infoText: { ...t.caption[1].regular, color: c.text.brand.assistive, flex: 1 },
+ students: { marginTop: gap[20] },
+ sectionTitle: {
+ ...t.body[5].medium,
+ color: c.text.default.normal,
+ marginLeft: padding[8],
+ marginBottom: gap[8],
+ },
+ list: { gap: gap[4] },
+ card: { padding: padding[16], backgroundColor: c.surface.subtle, borderRadius: radius.medium },
+ cardHeader: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ alignItems: 'center',
+ gap: gap[8],
+ },
+ student: { ...t.body[2].semiBold, color: c.text.default.neutral, flex: 1 },
+ badge: {
+ paddingHorizontal: padding[8],
+ paddingVertical: padding[4],
+ borderRadius: radius.small,
+ backgroundColor: c.surface.warning,
+ },
+ paidBadge: { backgroundColor: c.surface.success },
+ badgeText: { ...t.caption[1].medium, color: c.text.feedback.warning },
+ paidText: { color: c.text.feedback.success },
+ bill: { marginTop: gap[12] },
+ sessions: { ...t.body[5].medium, color: c.text.default.neutral },
+ billAmount: { ...t.body[4].semiBold, lineHeight: 20, color: c.text.default.neutral },
+ note: {
+ borderTopWidth: 1,
+ borderColor: c.border.default,
+ marginTop: gap[8],
+ paddingTop: padding[8],
+ },
+ noteText: { ...t.caption[1].regular, color: c.text.feedback.warning },
+ empty: { backgroundColor: c.surface.subtle, borderRadius: radius.medium, padding: padding[16] },
+ emptyText: { ...t.caption[1].regular, color: c.text.default.weak },
+ skeleton: { backgroundColor: primitive.gray[100], borderRadius: radius.small },
+ skeletonSummary: { height: 128, borderRadius: radius.large },
+ skeletonInfo: { height: 32, marginTop: gap[20] },
+ skeletonTitle: { width: 43, height: 22, marginTop: gap[20], marginBottom: gap[8] },
+ skeletonCard: { height: 138, marginBottom: gap[4], borderRadius: radius.medium },
+ error: {
+ padding: padding[24],
+ backgroundColor: c.surface.subtle,
+ borderRadius: radius.medium,
+ gap: gap[16],
+ },
+ errorText: { ...t.body[4].regular, color: c.text.default.weak },
+});
diff --git a/src/features/income/types.ts b/src/features/income/types.ts
new file mode 100644
index 0000000..0976e8c
--- /dev/null
+++ b/src/features/income/types.ts
@@ -0,0 +1,27 @@
+export type IncomeInvoice = {
+ id: string;
+ studentName: string;
+ sessionCount: number;
+ billedAmount: number;
+ receivedAmount: number;
+ parentReportedAt?: string;
+ reminderText?: string;
+};
+/** API 미확정: 화면 데이터 모델입니다. summary는 목록과 독립적인 집계 응답을 받을 수 있게 구분합니다. */
+export type IncomeData = {
+ summary: { expectedAmount: number; receivedAmount: number; outstandingAmount: number };
+ invoices: IncomeInvoice[];
+};
+export type IncomeState =
+ | { status: 'loading' }
+ | { status: 'ready'; data: IncomeData }
+ | { status: 'error'; message: string }
+ | { status: 'unavailable' };
+export function paymentStatus(invoice: IncomeInvoice) {
+ if (invoice.receivedAmount >= invoice.billedAmount) return '입금완료';
+ if (invoice.receivedAmount > 0) return '일부 입금';
+ return '입금 대기';
+}
+export function won(value: number) {
+ return `${new Intl.NumberFormat('ko-KR').format(value)}원`;
+}
diff --git a/src/features/main/MainTabBar.tsx b/src/features/main/MainTabBar.tsx
index b72dc93..68799c6 100644
--- a/src/features/main/MainTabBar.tsx
+++ b/src/features/main/MainTabBar.tsx
@@ -1,3 +1,14 @@
+import { Image } from 'expo-image';
+import homeIcon from '../../../assets/images/navigation/home.svg';
+import homeActiveIcon from '../../../assets/images/navigation/home-active.svg';
+import scheduleIcon from '../../../assets/images/navigation/schedule.svg';
+import scheduleActiveIcon from '../../../assets/images/navigation/schedule-active.svg';
+import studentsIcon from '../../../assets/images/navigation/students.svg';
+import studentsActiveIcon from '../../../assets/images/navigation/students-active.svg';
+import reportsIcon from '../../../assets/images/navigation/reports.svg';
+import reportsActiveIcon from '../../../assets/images/navigation/reports-active.svg';
+import incomeIcon from '../../../assets/images/navigation/income.svg';
+import incomeActiveIcon from '../../../assets/images/navigation/income-active.svg';
import type { BottomTabBarProps } from 'expo-router/js-tabs';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { primitive, semantic } from '@/styles/colors';
@@ -6,6 +17,13 @@ import { fontSize, fontWeight, letterSpacing, lineHeight } from '@/styles/typogr
// 아이콘 프레임(여백 포함) 크기
const ICON_SIZE = 32;
+const icons: Record = {
+ home: { inactive: homeIcon, active: homeActiveIcon },
+ schedule: { inactive: scheduleIcon, active: scheduleActiveIcon },
+ students: { inactive: studentsIcon, active: studentsActiveIcon },
+ reports: { inactive: reportsIcon, active: reportsActiveIcon },
+ income: { inactive: incomeIcon, active: incomeActiveIcon },
+};
export function MainTabBar({
state,
@@ -18,6 +36,7 @@ export function MainTabBar({
{state.routes.map((route, index) => {
const focused = index === state.index;
+ const icon = icons[route.name];
const { options } = descriptors[route.key];
const label = options.title ?? route.name;
const onPress = () => {
@@ -37,7 +56,17 @@ export function MainTabBar({
onPress={onPress}
style={s.item}
>
-
+ {icon ? (
+
+ ) : (
+
+ )}
{label}
);
@@ -64,5 +93,5 @@ const s = StyleSheet.create({
textAlign: 'center',
},
labelActive: { color: semantic.action.default },
- labelInactive: { color: semantic.icon.secondary },
+ labelInactive: { color: semantic.text.default.weak },
});
diff --git a/src/features/notifications/PushBannerProvider.tsx b/src/features/notifications/PushBannerProvider.tsx
new file mode 100644
index 0000000..70eae83
--- /dev/null
+++ b/src/features/notifications/PushBannerProvider.tsx
@@ -0,0 +1,89 @@
+import { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react';
+import type { ReactNode } from 'react';
+import { AppState, Platform, StyleSheet, View } from 'react-native';
+import { useSafeAreaInsets } from 'react-native-safe-area-context';
+import { PushBanner } from '@/components/PushBanner';
+export type BannerNotification = {
+ id?: string;
+ title: string;
+ message: string;
+ timeLabel?: string;
+ /** 기본 5초. 0이면 자동으로 닫지 않습니다. */ durationMs?: number;
+ onPress?: () => void;
+};
+type Entry = BannerNotification & { id: string; revision: number };
+type ContextValue = {
+ show: (notification: BannerNotification) => string;
+ dismiss: (id?: string) => void;
+};
+const Context = createContext(null);
+const MAX_QUEUE = 5;
+export function PushBannerProvider({ children }: { children: ReactNode }) {
+ const [queue, setQueue] = useState([]);
+ const [appState, setAppState] = useState(AppState.currentState);
+ const sequence = useRef(0);
+ const active = queue[0];
+ const insets = useSafeAreaInsets();
+ const show = useCallback((notification: BannerNotification) => {
+ const revision = ++sequence.current;
+ const id = notification.id?.trim() || `banner-${revision}`;
+ const entry = { ...notification, id, revision };
+ if (!notification.title.trim()) return id;
+ setQueue((current) => {
+ const exists = current.some((item) => item.id === id);
+ return exists
+ ? current.map((item) => (item.id === id ? entry : item))
+ : [...current, entry].slice(0, MAX_QUEUE);
+ });
+ return id;
+ }, []);
+ const dismiss = useCallback(
+ (id?: string) =>
+ setQueue((current) => (id ? current.filter((item) => item.id !== id) : current.slice(1))),
+ [],
+ );
+ useEffect(() => {
+ const sub = AppState.addEventListener('change', setAppState);
+ return () => sub.remove();
+ }, []);
+ useEffect(() => {
+ if (!active || appState !== 'active') return;
+ const duration = active.durationMs ?? 5000;
+ if (!Number.isFinite(duration) || duration <= 0) return;
+ const timer = setTimeout(() => dismiss(active.id), duration);
+ return () => clearTimeout(timer);
+ }, [active, appState, dismiss]);
+ return (
+
+ {children}
+ {active && appState === 'active' && (
+
+
+ dismiss(active.id)}
+ onPress={() => {
+ dismiss(active.id);
+ active.onPress?.();
+ }}
+ />
+
+
+ )}
+
+ );
+}
+export function usePushBanner() {
+ const context = useContext(Context);
+ if (!context) throw new Error('PushBannerProvider is required');
+ return context;
+}
+const s = StyleSheet.create({
+ layer: { position: 'absolute', left: 0, right: 0, zIndex: 1000, alignItems: 'center' },
+ width: { width: '100%', maxWidth: 390, paddingHorizontal: 10 },
+});