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 }, +});