From d0c8f640185dec93a4563517e6de52e9c23bfe15 Mon Sep 17 00:00:00 2001 From: sbjwin Date: Sun, 1 Nov 2026 22:59:00 +0900 Subject: [PATCH 1/3] =?UTF-8?q?=EC=9E=85=EB=A0=A5=EC=B0=BD=20=ED=82=A4?= =?UTF-8?q?=EB=B3=B4=EB=93=9C=20=EA=B0=80=EB=A6=BC=20=EB=B0=A9=EC=A7=80=20?= =?UTF-8?q?=EB=B0=8F=20=EB=82=A0=EC=A7=9C=C2=B7=EC=8B=9C=EA=B0=84=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=ED=94=BC=EC=BB=A4=20UX=20=EC=A0=84?= =?UTF-8?q?=EB=A9=B4=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: zigman1234 --- App.js | 1 + __tests__/Components.test.js | 28 ++ log/devlog.md | 29 ++ src/components/modals/CallManageModal.js | 24 +- src/components/modals/StudentStatusModal.js | 199 +++++--- src/components/modals/TimePickerModal.js | 464 ++++++++++++++++++ src/screens/CalendarScreen.js | 82 +++- src/screens/StudentDetailScreen.js | 222 +++++++-- src/screens/WeeklyPlanScreen.js | 490 ++++---------------- 9 files changed, 1029 insertions(+), 510 deletions(-) create mode 100644 src/components/modals/TimePickerModal.js diff --git a/App.js b/App.js index 3e1a074..b7397a8 100644 --- a/App.js +++ b/App.js @@ -45,6 +45,7 @@ function MainTabs({ navigation }) { }, tabBarActiveTintColor: theme.colors.primary || '#007AFF', tabBarInactiveTintColor: 'gray', + tabBarHideOnKeyboard: true, headerStyle: { backgroundColor: theme.colors.surface || '#FFFFFF', elevation: 0, diff --git a/__tests__/Components.test.js b/__tests__/Components.test.js index 193323b..e1753fd 100644 --- a/__tests__/Components.test.js +++ b/__tests__/Components.test.js @@ -6,6 +6,7 @@ import ConfirmModal from '../src/components/ConfirmModal'; import ActionMenuItem from '../src/components/ActionMenuItem'; import StatusBadge from '../src/components/StatusBadge'; import FloatingActionButton from '../src/components/FloatingActionButton'; +import TimePickerModal from '../src/components/modals/TimePickerModal'; describe('공통 컴포넌트 단위 테스트', () => { test('Separator 컴포넌트가 오류 없이 렌더링되어야 함', () => { @@ -96,5 +97,32 @@ describe('공통 컴포넌트 단위 테스트', () => { expect(extended).toBeDefined(); expect(extended.toJSON()).toBeTruthy(); }); + + test('TimePickerModal 컴포넌트가 visible=false 및 true일 때 정상 렌더링되어야 함', () => { + let hiddenModal; + let visibleModal; + act(() => { + hiddenModal = renderer.create( + + ); + visibleModal = renderer.create( + + ); + }); + expect(hiddenModal).toBeDefined(); + expect(visibleModal).toBeDefined(); + expect(visibleModal.toJSON()).toBeTruthy(); + }); }); diff --git a/log/devlog.md b/log/devlog.md index 1e70abb..3afa5fb 100644 --- a/log/devlog.md +++ b/log/devlog.md @@ -5,6 +5,35 @@ --- +## 2026-11-01: 전체 화면/모달 키보드 가림 방지 및 날짜·시간 피커 / 주간표 다중 학생 터치 UX 개선 (by Antigravity) + +* **반영 내역 (What landed)**: + * **공통 시간 선택 모달([src/components/modals/TimePickerModal.js](file:///d:/development/mydiary/src/components/modals/TimePickerModal.js)) 분리 신설**: + * 주간 시간표에 내장되어 있던 시작 시간 GUI 선택 팝업(빠른 프리셋, 오전/오후 탭, 시/분 그리드)을 재사용 가능한 공통 모달로 모듈화하여 [WeeklyPlanScreen.js](file:///d:/development/mydiary/src/screens/WeeklyPlanScreen.js) 라인 수를 3,089줄에서 2,760줄로 329줄 감축. + * **1번 과제 — 키보드 가림 현상 5곳 및 하단 탭바 자동 숨김 완비**: + * [App.js](file:///d:/development/mydiary/App.js): 하단 탭 네비게이터에 `tabBarHideOnKeyboard: true`를 적용하여 키보드 노출 시 하단 탭바가 입력 영역을 침범하지 않도록 개선. + * [src/screens/WeeklyPlanScreen.js](file:///d:/development/mydiary/src/screens/WeeklyPlanScreen.js): 하단 부가 업무(금주 주요사항, 전주 결석, 특이사항) 멀티라인 입력창 및 수업 추가/편집 모달의 하단 입력창(주소, 연락처, 변동 메모) 포커스 시 키보드 높이 연동 패딩 및 자동 스크롤(`scrollToEnd`) 적용. + * [src/screens/StudentDetailScreen.js](file:///d:/development/mydiary/src/screens/StudentDetailScreen.js): 키보드 노출 시 하단 고정 저장 바(`bottomFixedBar`)를 자동 숨김 처리하여 가용 화면을 확보하고, 정규 수업 과목 및 기타 사항(`notesInput`) 포커스 시 자동 스크롤 연동. + * [src/screens/CalendarScreen.js](file:///d:/development/mydiary/src/screens/CalendarScreen.js): 학생 선택 검색 모달에서 키보드 노출 시 모달을 화면 상단으로 배치하고 키보드 위쪽까지 가용 높이를 동적 계산하도록 개선. + * [src/components/modals/CallManageModal.js](file:///d:/development/mydiary/src/components/modals/CallManageModal.js) & [src/components/modals/StudentStatusModal.js](file:///d:/development/mydiary/src/components/modals/StudentStatusModal.js): 중앙 정렬 다이얼로그가 안드로이드 키보드에 가려지지 않도록 키보드 노출 시 상단 배치 전환. + * **2번 과제 — 날짜/시간 GUI 피커 일원화 및 주간 전체 표 다중 학생 터치 개선**: + * [src/screens/StudentDetailScreen.js](file:///d:/development/mydiary/src/screens/StudentDetailScreen.js): 수동 타이핑하던 '최초 수강 시작일(입회일)'에 인라인 달력(`Calendar`) 피커를 적용하고, '정규 수업 일정 시작 시간'에 공통 [`TimePickerModal`](file:///d:/development/mydiary/src/components/modals/TimePickerModal.js)을 연동하여 오타 원천 방지. + * [src/components/modals/StudentStatusModal.js](file:///d:/development/mydiary/src/components/modals/StudentStatusModal.js): 휴회 종료일 및 재수강 시작일 입력란을 인라인 달력(`Calendar`) 피커로 전환. + * [src/screens/WeeklyPlanScreen.js](file:///d:/development/mydiary/src/screens/WeeklyPlanScreen.js): 주간 전체 표(Table 뷰)의 동일 시간대 셀에 2명 이상 학생이 배정된 경우 각 학생 카드(`cellItemBox`)를 개별 터치하여 해당 학생의 일정을 바로 편집할 수 있도록 분리하고, 셀 빈 영역 터치 시 새 수업 추가 모달이 열리도록 개선. + * **단위 테스트 추가**: + * [__tests__/Components.test.js](file:///d:/development/mydiary/__tests__/Components.test.js): [`TimePickerModal`](file:///d:/development/mydiary/src/components/modals/TimePickerModal.js) 렌더링 검증 테스트 추가 (총 31개 테스트). + +* **검증 수치 (Numbers)**: + * 올인원 품질 게이트 (`npm run gate`): 100% 통과 + * ESLint 무결성: 에러 0건, 경고 0건 + * Jest 단위 테스트: 6개 스위트, 31개 테스트 전체 통과 (100% PASS) + * 정기 프로젝트 건전성 정밀 감사: 96점 / 100점 달성 + +* **미해결 과제 (Still open)**: + * [docs/gaps.md](file:///d:/development/mydiary/docs/gaps.md)의 `[G1]` HWPX 복수 표 서식 레이아웃 프리셋(표준형, 서술형, 결재란 포함형) 다변화 작업 대기 중. + +--- + ## 2026-10-27: Antigravity 결정론적 라이프사이클 훅(hooks.json) 구축 및 토큰 소모 최적화 (by Antigravity) * **반영 내역 (What landed)**: diff --git a/src/components/modals/CallManageModal.js b/src/components/modals/CallManageModal.js index dba20ee..c76f853 100644 --- a/src/components/modals/CallManageModal.js +++ b/src/components/modals/CallManageModal.js @@ -29,15 +29,24 @@ export default function CallManageModal({ const { theme: activeTheme } = useTheme(); const theme = activeTheme || defaultTheme; const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); + const [keyboardHeight, setKeyboardHeight] = useState(0); useEffect(() => { const showSub = Keyboard.addListener( Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow', - () => setIsKeyboardVisible(true) + (e) => { + setIsKeyboardVisible(true); + if (e?.endCoordinates?.height) { + setKeyboardHeight(e.endCoordinates.height); + } + } ); const hideSub = Keyboard.addListener( Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide', - () => setIsKeyboardVisible(false) + () => { + setIsKeyboardVisible(false); + setKeyboardHeight(0); + } ); return () => { showSub.remove(); @@ -51,13 +60,18 @@ export default function CallManageModal({ transparent={true} visible={visible} onRequestClose={onClose} + statusBarTranslucent > { if (isKeyboardVisible) { @@ -134,6 +148,10 @@ const styles = StyleSheet.create({ justifyContent: 'center', paddingHorizontal: 24, }, + modalOverlayKeyboard: { + justifyContent: 'flex-start', + paddingTop: Platform.OS === 'android' ? 48 : 64, + }, callModalContent: { borderRadius: 18, padding: 20, diff --git a/src/components/modals/StudentStatusModal.js b/src/components/modals/StudentStatusModal.js index 92eeabc..2165b82 100644 --- a/src/components/modals/StudentStatusModal.js +++ b/src/components/modals/StudentStatusModal.js @@ -1,15 +1,17 @@ -import React from 'react'; +import React, { useState, useEffect } from 'react'; import { Modal, View, Text, TextInput, TouchableOpacity, + ScrollView, StyleSheet, Platform, KeyboardAvoidingView, Keyboard, } from 'react-native'; +import { Calendar } from 'react-native-calendars'; import { Feather } from '@expo/vector-icons'; import { useTheme } from '../../context/ThemeContext'; import { theme as defaultTheme } from '../../theme'; @@ -31,6 +33,33 @@ export default function StudentStatusModal({ const { theme: activeTheme } = useTheme(); const theme = activeTheme || defaultTheme; + const [showDatePicker, setShowDatePicker] = useState(false); + const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); + + useEffect(() => { + if (!visible) { + setShowDatePicker(false); + } + }, [visible]); + + useEffect(() => { + const showSub = Keyboard.addListener( + Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow', + () => { + setIsKeyboardVisible(true); + setShowDatePicker(false); + } + ); + const hideSub = Keyboard.addListener( + Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide', + () => setIsKeyboardVisible(false) + ); + return () => { + showSub.remove(); + hideSub.remove(); + }; + }, []); + const isPause = type === 'pause'; const iconName = isPause ? 'pause-circle' : 'play-circle'; const iconColor = isPause ? theme.colors.warning : theme.colors.success; @@ -52,17 +81,24 @@ export default function StudentStatusModal({ transparent={true} visible={visible} onRequestClose={onClose} + statusBarTranslucent > { - Keyboard.dismiss(); - onClose(); + if (isKeyboardVisible) { + Keyboard.dismiss(); + } else { + onClose(); + } }} > e.stopPropagation()} > - - - {title} - - - {subtitle} - - - {dateLabel} - + + + + {title} + + + {subtitle} + - {reasonLabel} - - - + {dateLabel} { + Keyboard.dismiss(); + setShowDatePicker(!showDatePicker); + }} > - 취소 + + 📅 {date || 'YYYY-MM-DD'} + + - - {confirmBtnText} - - + + {showDatePicker && ( + + { + onDateChange(day.dateString); + setShowDatePicker(false); + }} + theme={{ + selectedDayBackgroundColor: theme.colors.primary, + todayTextColor: theme.colors.primary, + arrowColor: theme.colors.primary, + }} + /> + + )} + + {reasonLabel} + + + + + 취소 + + + {confirmBtnText} + + + @@ -142,9 +208,14 @@ const styles = StyleSheet.create({ alignItems: 'center', padding: 20, }, + centerModalOverlayKeyboard: { + justifyContent: 'flex-start', + paddingTop: Platform.OS === 'android' ? 40 : 56, + }, dialogCard: { width: '100%', maxWidth: 380, + maxHeight: '88%', borderRadius: 18, padding: 22, shadowColor: '#000', @@ -184,6 +255,26 @@ const styles = StyleSheet.create({ fontSize: 14, marginBottom: 12, }, + dateSelectorBtn: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + borderWidth: 1, + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 11, + marginBottom: 12, + }, + dateSelectorText: { + fontSize: 14, + fontWeight: '600', + }, + inlineCalendar: { + marginBottom: 12, + borderWidth: 1, + borderRadius: 10, + overflow: 'hidden', + }, dialogBtnRow: { flexDirection: 'row', gap: 10, diff --git a/src/components/modals/TimePickerModal.js b/src/components/modals/TimePickerModal.js new file mode 100644 index 0000000..7bb1482 --- /dev/null +++ b/src/components/modals/TimePickerModal.js @@ -0,0 +1,464 @@ +import React, { useMemo } from 'react'; +import { + Modal, + View, + Text, + TouchableOpacity, + ScrollView, + StyleSheet, +} from 'react-native'; +import { Feather } from '@expo/vector-icons'; +import { useTheme } from '../../context/ThemeContext'; +import { theme as defaultTheme } from '../../theme'; + +export const MORNING_HOURS = [ + { hour24: 6, label: '06시' }, + { hour24: 7, label: '07시' }, + { hour24: 8, label: '08시' }, + { hour24: 9, label: '09시' }, + { hour24: 10, label: '10시' }, + { hour24: 11, label: '11시' }, + { hour24: 12, label: '12시' }, +]; + +export const AFTERNOON_HOURS = [ + { hour24: 13, label: '13시 (1시)' }, + { hour24: 14, label: '14시 (2시)' }, + { hour24: 15, label: '15시 (3시)' }, + { hour24: 16, label: '16시 (4시)' }, + { hour24: 17, label: '17시 (5시)' }, + { hour24: 18, label: '18시 (6시)' }, + { hour24: 19, label: '19시 (7시)' }, + { hour24: 20, label: '20시 (8시)' }, + { hour24: 21, label: '21시 (9시)' }, + { hour24: 22, label: '22시 (10시)' }, + { hour24: 23, label: '23시 (11시)' }, +]; + +export const MINUTE_OPTIONS = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55']; + +export const QUICK_TIME_PRESETS = [ + '09:00', + '10:00', + '10:30', + '11:00', + '11:30', + '13:00', + '13:30', + '14:00', + '14:30', + '15:00', + '15:30', + '16:00', + '16:30', + '17:00', + '18:00', + '19:00', + '20:00', +]; + +/** + * 시작 시간 GUI 선택 공통 팝업 모달 + */ +export default function TimePickerModal({ + visible, + time = '10:00', + title = '시작 시간 선택', + onSelectTime, + onClose, +}) { + const { theme: activeTheme } = useTheme(); + const theme = activeTheme || defaultTheme; + + const parsedTime = useMemo(() => { + if (!time) { + return { ampm: '오전', hour24: 10, minute: '00', isEmpty: true }; + } + const match = time.match(/^(\d{1,2}):(\d{2})/); + if (match) { + const h = parseInt(match[1], 10); + const m = match[2]; + return { + ampm: h >= 12 ? '오후' : '오전', + hour24: h, + minute: m, + isEmpty: false, + }; + } + return { ampm: '오전', hour24: 10, minute: '00', isEmpty: false }; + }, [time]); + + const handleAmpmChange = (newAmpm) => { + let targetHour = parsedTime.hour24; + if (newAmpm === '오전' && targetHour >= 12) { + targetHour = targetHour === 12 ? 0 : targetHour - 12; + } else if (newAmpm === '오후' && targetHour < 12) { + targetHour = targetHour === 0 ? 12 : targetHour + 12; + } + const m = parsedTime.minute || '00'; + onSelectTime(`${String(targetHour).padStart(2, '0')}:${m}`); + }; + + const handleHourChange = (h24) => { + const m = parsedTime.minute || '00'; + onSelectTime(`${String(h24).padStart(2, '0')}:${m}`); + }; + + const handleMinuteChange = (mStr) => { + const h24 = parsedTime.hour24; + onSelectTime(`${String(h24).padStart(2, '0')}:${mStr}`); + }; + + const displayHour12 = parsedTime.hour24 >= 12 + ? (parsedTime.hour24 === 12 ? 12 : parsedTime.hour24 - 12) + : (parsedTime.hour24 === 0 ? 12 : parsedTime.hour24); + + return ( + + + true} + > + + + + {title} + + + + + + + + {/* 현재 선택된 시간 디스플레이 */} + + + {time || '10:00'} + + + {parsedTime.ampm} {displayHour12}시 {parsedTime.minute}분 + + + + {/* 1. 빠른 프리셋 */} + + ⚡ 추천 / 빠른 선택 + + + {QUICK_TIME_PRESETS.map((preset) => { + const isSelected = time === preset; + return ( + onSelectTime(preset)} + > + + {preset} + + + ); + })} + + + {/* 2. 오전/오후 탭 */} + + handleAmpmChange('오전')} + > + + 오전 (AM) + + + handleAmpmChange('오후')} + > + + 오후 (PM) + + + + + {/* 3. 시(Hour) 선택 */} + + {parsedTime.ampm === '오전' ? '오전 시간 (시)' : '오후 시간 (시)'} + + + {(parsedTime.ampm === '오전' ? MORNING_HOURS : AFTERNOON_HOURS).map((h) => { + const isSelected = parsedTime.hour24 === h.hour24 && !parsedTime.isEmpty; + return ( + handleHourChange(h.hour24)} + > + + {h.label} + + + ); + })} + + + {/* 4. 분(Minute) 선택 */} + + 분 (Min) + + + {MINUTE_OPTIONS.map((minStr) => { + const isSelected = parsedTime.minute === minStr && !parsedTime.isEmpty; + return ( + handleMinuteChange(minStr)} + > + + {minStr}분 + + + ); + })} + + + {/* 확인 완료 버튼 */} + + + + {time ? `${time} 선택 완료` : '선택 완료'} + + + + + + + ); +} + +const styles = StyleSheet.create({ + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.45)', + justifyContent: 'flex-end', + }, + timePickerModalContent: { + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + padding: 20, + maxHeight: '85%', + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 16, + paddingBottom: 10, + borderBottomWidth: 1, + }, + timeModalTitleRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + modalTitle: { + fontSize: 17, + fontWeight: 'bold', + }, + currentTimeDisplayBox: { + borderRadius: 12, + paddingVertical: 12, + alignItems: 'center', + marginBottom: 14, + borderWidth: 1, + }, + currentTimeDisplayText: { + fontSize: 28, + fontWeight: '800', + letterSpacing: 2, + }, + currentTimeDisplaySub: { + fontSize: 12.5, + fontWeight: '600', + marginTop: 2, + }, + timePickerSectionTitle: { + fontSize: 12, + fontWeight: '700', + marginBottom: 6, + marginTop: 8, + }, + quickPresetScroll: { + paddingVertical: 2, + marginBottom: 10, + }, + quickPresetChip: { + paddingHorizontal: 12, + paddingVertical: 6, + borderRadius: 16, + marginRight: 6, + borderWidth: 1, + }, + quickPresetText: { + fontSize: 12.5, + fontWeight: '600', + }, + quickPresetTextActive: { + fontWeight: 'bold', + }, + timeAmpmRow: { + flexDirection: 'row', + borderRadius: 8, + padding: 3, + marginBottom: 8, + marginTop: 4, + }, + timeAmpmTab: { + flex: 1, + paddingVertical: 7, + alignItems: 'center', + borderRadius: 6, + }, + timeAmpmTabActive: { + shadowColor: '#000', + shadowOffset: { width: 0, height: 1 }, + shadowOpacity: 0.1, + elevation: 2, + }, + timeAmpmTabText: { + fontSize: 13, + fontWeight: '600', + }, + timeAmpmTabTextActive: { + fontWeight: 'bold', + }, + timeGridRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 6, + marginBottom: 10, + }, + timeGridBtn: { + flexBasis: '22.5%', + flexGrow: 1, + paddingVertical: 8, + borderRadius: 8, + alignItems: 'center', + borderWidth: 1, + }, + timeGridBtnText: { + fontSize: 12, + fontWeight: '500', + }, + timeGridBtnTextActive: { + fontWeight: 'bold', + }, + timeConfirmBtn: { + flexDirection: 'row', + justifyContent: 'center', + alignItems: 'center', + gap: 6, + marginTop: 12, + marginBottom: 16, + paddingVertical: 12, + borderRadius: 10, + }, + timeConfirmBtnText: { + fontSize: 14, + fontWeight: 'bold', + }, +}); + diff --git a/src/screens/CalendarScreen.js b/src/screens/CalendarScreen.js index ae32ba5..9d699b8 100644 --- a/src/screens/CalendarScreen.js +++ b/src/screens/CalendarScreen.js @@ -12,7 +12,9 @@ import { Animated, PanResponder, KeyboardAvoidingView, - Platform + Keyboard, + Platform, + useWindowDimensions } from 'react-native'; import { Calendar, LocaleConfig } from 'react-native-calendars'; import { Feather } from '@expo/vector-icons'; @@ -79,6 +81,51 @@ export default function CalendarScreen() { const [loading, setLoading] = useState(true); const [studentSelectVisible, setStudentSelectVisible] = useState(false); const [markedDates, setMarkedDates] = useState({}); + const { height: screenHeight } = useWindowDimensions(); + const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); + const [keyboardHeight, setKeyboardHeight] = useState(0); + + useEffect(() => { + const showSub = Keyboard.addListener( + Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow', + (e) => { + setIsKeyboardVisible(true); + if (e?.endCoordinates?.height) { + setKeyboardHeight(e.endCoordinates.height); + } + } + ); + const hideSub = Keyboard.addListener( + Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide', + () => { + setIsKeyboardVisible(false); + setKeyboardHeight(0); + } + ); + return () => { + showSub.remove(); + hideSub.remove(); + }; + }, []); + + // 키보드 상태 및 화면 크기에 따른 학생 선택 모달 동적 스타일 (키보드 가림 방지) + const modalOverlayDynamicStyle = useMemo(() => [ + styles.modalOverlay, + isKeyboardVisible && styles.modalOverlayKeyboard, + ], [isKeyboardVisible]); + + const modalContentDynamicStyle = useMemo(() => { + if (!isKeyboardVisible) { + return [styles.modalContent, { maxHeight: Math.min(screenHeight * 0.75, 540) }]; + } + const safeTop = Platform.OS === 'android' ? 36 : 48; + const availableHeight = screenHeight - keyboardHeight - safeTop - 20; + return [ + styles.modalContent, + styles.modalContentKeyboard, + { height: undefined, maxHeight: Math.max(availableHeight, 240) }, + ]; + }, [isKeyboardVisible, keyboardHeight, screenHeight]); // 달력 접기/펼치기 상태 및 애니메이션 관리를 위한 상태 및 ref const [isCollapsed, setIsCollapsed] = useState(false); @@ -481,23 +528,38 @@ export default function CalendarScreen() { animationType="slide" transparent={true} visible={studentSelectVisible} - onRequestClose={() => setStudentSelectVisible(false)} + onRequestClose={() => { + if (isKeyboardVisible) { + Keyboard.dismiss(); + } else { + setStudentSelectVisible(false); + } + }} statusBarTranslucent > setStudentSelectVisible(false)} + onPress={() => { + if (isKeyboardVisible) { + Keyboard.dismiss(); + } else { + setStudentSelectVisible(false); + } + }} /> - + 학생 선택 setStudentSelectVisible(false)} + onPress={() => { + Keyboard.dismiss(); + setStudentSelectVisible(false); + }} hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} > @@ -747,6 +809,11 @@ const styles = StyleSheet.create({ backgroundColor: 'rgba(0, 0, 0, 0.5)', justifyContent: 'flex-end', }, + modalOverlayKeyboard: { + justifyContent: 'flex-start', + paddingTop: Platform.OS === 'android' ? 36 : 48, + paddingHorizontal: 14, + }, modalBackdrop: { ...StyleSheet.absoluteFillObject, }, @@ -757,6 +824,9 @@ const styles = StyleSheet.create({ height: '75%', padding: 20, }, + modalContentKeyboard: { + borderRadius: 20, + }, modalHeader: { flexDirection: 'row', justifyContent: 'space-between', diff --git a/src/screens/StudentDetailScreen.js b/src/screens/StudentDetailScreen.js index 55981b4..da46ac2 100644 --- a/src/screens/StudentDetailScreen.js +++ b/src/screens/StudentDetailScreen.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useLayoutEffect } from 'react'; +import React, { useState, useEffect, useLayoutEffect, useRef, useMemo } from 'react'; import { StyleSheet, View, @@ -9,12 +9,14 @@ import { SafeAreaView, Alert, KeyboardAvoidingView, + Keyboard, Platform, ImageBackground, Modal } from 'react-native'; import { useNavigation, useRoute } from '@react-navigation/native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; +import { Calendar } from 'react-native-calendars'; import { Database } from '../database/Database'; import { printStudentProfile, shareStudentProfile } from '../services/PrintService'; import { Feather } from '@expo/vector-icons'; @@ -22,6 +24,7 @@ import { useTheme } from '../context/ThemeContext'; import { getTodayFormatted } from '../utils/dateUtils'; import { theme } from '../theme'; import StudentStatusModal from '../components/modals/StudentStatusModal'; +import TimePickerModal from '../components/modals/TimePickerModal'; import ActionMenuItem from '../components/ActionMenuItem'; export default function StudentDetailScreen() { @@ -72,6 +75,49 @@ export default function StudentDetailScreen() { // 출력 모달 제어 상태 const [printModalVisible, setPrintModalVisible] = useState(false); + // 입회일 달력 및 정규 수업 시간 피커 상태 + const scrollRef = useRef(null); + const [showEnrolledDatePicker, setShowEnrolledDatePicker] = useState(false); + const [activeScheduleIdForTime, setActiveScheduleIdForTime] = useState(null); + const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); + const [keyboardHeight, setKeyboardHeight] = useState(0); + + useEffect(() => { + const showSub = Keyboard.addListener( + Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow', + (e) => { + setIsKeyboardVisible(true); + if (e?.endCoordinates?.height) { + setKeyboardHeight(e.endCoordinates.height); + } + } + ); + const hideSub = Keyboard.addListener( + Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide', + () => { + setIsKeyboardVisible(false); + setKeyboardHeight(0); + } + ); + return () => { + showSub.remove(); + hideSub.remove(); + }; + }, []); + + const scrollContentDynamicStyle = useMemo(() => [ + styles.scrollContent, + isKeyboardVisible && { + paddingBottom: Platform.OS === 'android' ? keyboardHeight + 48 : 80, + }, + ], [isKeyboardVisible, keyboardHeight]); + + const activeScheduleTime = useMemo(() => { + if (!activeScheduleIdForTime) return '10:00'; + const found = defaultSchedules.find((s) => s.id === activeScheduleIdForTime); + return found?.startTime || '10:00'; + }, [activeScheduleIdForTime, defaultSchedules]); + // 현재 입력 상태 기반 학생 객체 const getCurrentStudentData = () => ({ name: name.trim(), @@ -338,10 +384,15 @@ export default function StudentDetailScreen() { return ( - + {/* 수강 상태 및 차수(Terms) 이력 */} @@ -400,16 +451,46 @@ export default function StudentDetailScreen() { )} - {/* 수강 시작일 (입회일) 수정 */} + {/* 수강 시작일 (입회일) 선택 */} 최초 수강 시작일 (입회일) - + { + Keyboard.dismiss(); + setShowEnrolledDatePicker(!showEnrolledDatePicker); + }} + > + + 📅 {firstEnrolledDate || 'YYYY-MM-DD'} + + + + + {showEnrolledDatePicker && ( + + { + setFirstEnrolledDate(day.dateString); + setShowEnrolledDatePicker(false); + }} + theme={{ + selectedDayBackgroundColor: theme.colors.primary, + todayTextColor: theme.colors.primary, + arrowColor: theme.colors.primary, + }} + /> + + )} {/* 수강 차수 이력 목록 */} @@ -674,13 +755,18 @@ export default function StudentDetailScreen() { 시작 시간 - handleUpdateDefaultSchedule(sched.id, 'startTime', val)} - placeholder="10:00" - placeholderTextColor={theme.colors.outline} - /> + { + Keyboard.dismiss(); + setActiveScheduleIdForTime(sched.id); + }} + > + + ⏰ {sched.startTime || '10:00'} + + + 수업 과목/내용 @@ -688,6 +774,11 @@ export default function StudentDetailScreen() { style={styles.schedInput} value={sched.subject || ''} onChangeText={(val) => handleUpdateDefaultSchedule(sched.id, 'subject', val)} + onFocus={() => { + setTimeout(() => { + scrollRef.current?.scrollToEnd({ animated: true }); + }, 150); + }} placeholder="예: 파이썬, C언어, 자바" placeholderTextColor={theme.colors.outline} /> @@ -709,6 +800,11 @@ export default function StudentDetailScreen() { style={styles.notesInput} value={notes} onChangeText={setNotes} + onFocus={() => { + setTimeout(() => { + scrollRef.current?.scrollToEnd({ animated: true }); + }, 150); + }} placeholder="학생에 대한 특이사항이나 수업 참고 내용을 입력하세요" placeholderTextColor={theme.colors.outline} multiline={true} @@ -732,28 +828,30 @@ export default function StudentDetailScreen() { - {/* Fixed Bottom Actions */} - - - - - 저장하기 - - - {isEditMode && ( + {/* Fixed Bottom Actions (키보드 노출 시 입력 영역 확보를 위해 자동 숨김) */} + {!isKeyboardVisible && ( + + - - 학생 정보 삭제 + + 저장하기 - )} + + {isEditMode && ( + + + 학생 정보 삭제 + + )} + - + )} {/* 학생 카드 출력 모달 (프린터 인쇄 vs PDF 공유) */} setResumeModalVisible(false)} /> + {/* 정규 수업 시작 시간 선택 모달 */} + { + if (activeScheduleIdForTime) { + handleUpdateDefaultSchedule(activeScheduleIdForTime, 'startTime', newTime); + } + }} + onClose={() => setActiveScheduleIdForTime(null)} + /> + ); @@ -1213,7 +1324,7 @@ const styles = StyleSheet.create({ gap: 10, }, schedInputColSmall: { - width: 90, + width: 105, }, schedInputColLarge: { flex: 1, @@ -1234,6 +1345,43 @@ const styles = StyleSheet.create({ fontSize: 13, color: theme.colors.textPrimary, }, + schedTimeSelectorBtn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: theme.colors.surfaceVariant + '50', + borderWidth: 1, + borderColor: theme.colors.outline + '40', + borderRadius: 6, + paddingHorizontal: 8, + paddingVertical: 7, + }, + schedTimeSelectorText: { + fontSize: 13, + fontWeight: '700', + color: theme.colors.textPrimary, + }, + dateSelectorBtn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + dateSelectorBtnActive: { + borderColor: theme.colors.primary, + backgroundColor: theme.colors.secondaryContainer + '40', + }, + dateSelectorText: { + fontSize: 14, + fontWeight: '600', + color: theme.colors.textPrimary, + }, + inlineCalendar: { + marginTop: 8, + borderWidth: 1, + borderColor: theme.colors.secondaryContainer, + borderRadius: theme.roundness, + overflow: 'hidden', + }, statusBadge: { paddingHorizontal: 8, paddingVertical: 3, diff --git a/src/screens/WeeklyPlanScreen.js b/src/screens/WeeklyPlanScreen.js index 7ae438a..954ef38 100644 --- a/src/screens/WeeklyPlanScreen.js +++ b/src/screens/WeeklyPlanScreen.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { StyleSheet, View, @@ -26,6 +26,7 @@ import { useTheme } from '../context/ThemeContext'; import { theme } from '../theme'; import CallManageModal from '../components/modals/CallManageModal'; import WeeklyExportModal from '../components/modals/WeeklyExportModal'; +import TimePickerModal from '../components/modals/TimePickerModal'; const DAY_LABELS = ['월', '화', '수', '목', '금', '토', '일']; @@ -44,53 +45,6 @@ const TIME_SLOTS = [ { label: '8시', hour: 20 }, ]; -// 시작 시간 GUI 선택 팝업용 상수 -const MORNING_HOURS = [ - { hour24: 6, label: '06시' }, - { hour24: 7, label: '07시' }, - { hour24: 8, label: '08시' }, - { hour24: 9, label: '09시' }, - { hour24: 10, label: '10시' }, - { hour24: 11, label: '11시' }, - { hour24: 12, label: '12시' }, -]; - -const AFTERNOON_HOURS = [ - { hour24: 13, label: '13시 (1시)' }, - { hour24: 14, label: '14시 (2시)' }, - { hour24: 15, label: '15시 (3시)' }, - { hour24: 16, label: '16시 (4시)' }, - { hour24: 17, label: '17시 (5시)' }, - { hour24: 18, label: '18시 (6시)' }, - { hour24: 19, label: '19시 (7시)' }, - { hour24: 20, label: '20시 (8시)' }, - { hour24: 21, label: '21시 (9시)' }, - { hour24: 22, label: '22시 (10시)' }, - { hour24: 23, label: '23시 (11시)' }, -]; - -const MINUTE_OPTIONS = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55']; - -const QUICK_TIME_PRESETS = [ - '09:00', - '10:00', - '10:30', - '11:00', - '11:30', - '13:00', - '13:30', - '14:00', - '14:30', - '15:00', - '15:30', - '16:00', - '16:30', - '17:00', - '18:00', - '19:00', - '20:00', -]; - export default function WeeklyPlanScreen() { // eslint-disable-next-line no-shadow const { theme } = useTheme(); @@ -133,6 +87,9 @@ export default function WeeklyPlanScreen() { const [timePickerVisible, setTimePickerVisible] = useState(false); const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); const [keyboardHeight, setKeyboardHeight] = useState(0); + const mainScrollRef = useRef(null); + const editModalScrollRef = useRef(null); + const focusedModalFieldRef = useRef(null); useEffect(() => { const showSub = Keyboard.addListener( @@ -142,6 +99,11 @@ export default function WeeklyPlanScreen() { if (e?.endCoordinates?.height) { setKeyboardHeight(e.endCoordinates.height); } + if (focusedModalFieldRef.current === 'bottom') { + setTimeout(() => { + editModalScrollRef.current?.scrollToEnd({ animated: true }); + }, 80); + } } ); const hideSub = Keyboard.addListener( @@ -149,6 +111,7 @@ export default function WeeklyPlanScreen() { () => { setIsKeyboardVisible(false); setKeyboardHeight(0); + focusedModalFieldRef.current = null; } ); return () => { @@ -157,48 +120,19 @@ export default function WeeklyPlanScreen() { }; }, []); - // 시작 시간 파싱 헬퍼 (HH:mm 포맷) - const parsedStartTime = useMemo(() => { - if (!formStartTime) { - return { ampm: '오전', hour24: 10, minute: '00', isEmpty: true }; - } - const match = formStartTime.match(/^(\d{1,2}):(\d{2})/); - if (match) { - const h = parseInt(match[1], 10); - const m = match[2]; - return { - ampm: h >= 12 ? '오후' : '오전', - hour24: h, - minute: m, - isEmpty: false, - }; - } - return { ampm: '오전', hour24: 10, minute: '00', isEmpty: false }; - }, [formStartTime]); - - // 오전/오후 변경 핸들러 - const handleStartTimeAmpmChange = (newAmpm) => { - let targetHour = parsedStartTime.hour24; - if (newAmpm === '오전' && targetHour >= 12) { - targetHour = targetHour === 12 ? 0 : targetHour - 12; - } else if (newAmpm === '오후' && targetHour < 12) { - targetHour = targetHour === 0 ? 12 : targetHour + 12; - } - const m = parsedStartTime.minute || '00'; - setFormStartTime(`${String(targetHour).padStart(2, '0')}:${m}`); - }; - - // 시(Hour) 선택 핸들러 - const handleStartTimeHourChange = (h24) => { - const m = parsedStartTime.minute || '00'; - setFormStartTime(`${String(h24).padStart(2, '0')}:${m}`); - }; + const mainScrollContentDynamicStyle = useMemo(() => [ + styles.mainScrollContent, + isKeyboardVisible && { + paddingBottom: Platform.OS === 'android' ? keyboardHeight + 40 : 120, + }, + ], [isKeyboardVisible, keyboardHeight]); - // 분(Minute) 선택 핸들러 - const handleStartTimeMinuteChange = (mStr) => { - const h24 = parsedStartTime.hour24; - setFormStartTime(`${String(h24).padStart(2, '0')}:${mStr}`); - }; + const editModalScrollContentDynamicStyle = useMemo(() => [ + styles.modalScrollContent, + isKeyboardVisible && { + paddingBottom: Platform.OS === 'android' ? Math.max(keyboardHeight * 0.6, 120) : 48, + }, + ], [isKeyboardVisible, keyboardHeight]); // 작성된 수업 일지 목록 (실시간 완료 매핑용) const [allRecords, setAllRecords] = useState([]); @@ -840,7 +774,13 @@ export default function WeeklyPlanScreen() { 주간 시간표를 불러오는 중입니다... ) : ( - + {/* ======================= 보기 1: 주간 전체 타임테이블 (PDF 서식) ======================= */} {viewMode === 'table' && ( @@ -891,19 +831,18 @@ export default function WeeklyPlanScreen() { key={dayVal} style={styles.scheduleCell} activeOpacity={0.7} - onPress={() => { - if (items.length > 0) { - openEditModal(items[0]); - } else { - openAddModal(dayVal, slot.hour); - } - }} + onPress={() => openAddModal(dayVal, slot.hour)} > {items.map((item) => { const matchedRec = getMatchedRecord(item); const isDone = Boolean(matchedRec); return ( - + openEditModal(item)} + > {item.startTime} {item.studentName} @@ -920,7 +859,7 @@ export default function WeeklyPlanScreen() { {item.statusNote} ) : null} - + ); })} {items.length === 0 && ( @@ -1165,6 +1104,11 @@ export default function WeeklyPlanScreen() { style={styles.noteTextInput} value={weeklyPlan?.mainNotes || ''} onChangeText={(val) => handleSaveNotes('mainNotes', val)} + onFocus={() => { + setTimeout(() => { + mainScrollRef.current?.scrollToEnd({ animated: true }); + }, 150); + }} placeholder="#개학후 시간변동 체크 #마감보고서 제출" placeholderTextColor={theme.colors.outline} multiline={true} @@ -1179,6 +1123,11 @@ export default function WeeklyPlanScreen() { style={styles.noteTextInput} value={weeklyPlan?.prevAbsentNotes || ''} onChangeText={(val) => handleSaveNotes('prevAbsentNotes', val)} + onFocus={() => { + setTimeout(() => { + mainScrollRef.current?.scrollToEnd({ animated: true }); + }, 150); + }} placeholder="#유귀일: 개인사정" placeholderTextColor={theme.colors.outline} multiline={true} @@ -1193,6 +1142,11 @@ export default function WeeklyPlanScreen() { style={styles.noteTextInput} value={weeklyPlan?.specialNotes || ''} onChangeText={(val) => handleSaveNotes('specialNotes', val)} + onFocus={() => { + setTimeout(() => { + mainScrollRef.current?.scrollToEnd({ animated: true }); + }, 150); + }} placeholder="공지사항이나 전달사항을 입력하세요" placeholderTextColor={theme.colors.outline} multiline={true} @@ -1275,9 +1229,11 @@ export default function WeeklyPlanScreen() { {/* 학생 선택 버튼 */} @@ -1358,6 +1314,12 @@ export default function WeeklyPlanScreen() { style={[styles.formInput, styles.multilineInput]} value={formAddress} onChangeText={setFormAddress} + onFocus={() => { + focusedModalFieldRef.current = 'bottom'; + setTimeout(() => { + editModalScrollRef.current?.scrollTo({ y: 160, animated: true }); + }, 120); + }} placeholder="거주지 주소" placeholderTextColor={theme.colors.outline} multiline={true} @@ -1370,6 +1332,12 @@ export default function WeeklyPlanScreen() { style={[styles.formInput, styles.multilineInput]} value={formPhoneInfo} onChangeText={setFormPhoneInfo} + onFocus={() => { + focusedModalFieldRef.current = 'bottom'; + setTimeout(() => { + editModalScrollRef.current?.scrollToEnd({ animated: true }); + }, 120); + }} placeholder="(모)010-0000-0000 (본)010-0000-0000" placeholderTextColor={theme.colors.outline} multiline={true} @@ -1394,6 +1362,12 @@ export default function WeeklyPlanScreen() { style={styles.formInput} value={formStatusNote} onChangeText={setFormStatusNote} + onFocus={() => { + focusedModalFieldRef.current = 'bottom'; + setTimeout(() => { + editModalScrollRef.current?.scrollToEnd({ animated: true }); + }, 120); + }} placeholder="예: => 이번주만, => 보강" placeholderTextColor={theme.colors.outline} /> @@ -1573,176 +1547,13 @@ export default function WeeklyPlanScreen() { /> {/* ======================= 모달 5: 시작 시간 GUI 선택 팝업 ======================= */} - setTimePickerVisible(false)} - > - setTimePickerVisible(false)} - > - true}> - - - - 시작 시간 선택 - - setTimePickerVisible(false)}> - - - - - - {/* 현재 선택된 시간 디스플레이 */} - - - {formStartTime || '10:00'} - - - {parsedStartTime.ampm} {parsedStartTime.hour24 >= 12 ? (parsedStartTime.hour24 === 12 ? 12 : parsedStartTime.hour24 - 12) : (parsedStartTime.hour24 === 0 ? 12 : parsedStartTime.hour24)}시 {parsedStartTime.minute}분 - - - - {/* 1. 빠른 프리셋 */} - ⚡ 추천 / 빠른 선택 - - {QUICK_TIME_PRESETS.map((preset) => { - const isSelected = formStartTime === preset; - return ( - setFormStartTime(preset)} - > - - {preset} - - - ); - })} - - - {/* 2. 오전/오후 탭 */} - - handleStartTimeAmpmChange('오전')} - > - - 오전 (AM) - - - handleStartTimeAmpmChange('오후')} - > - - 오후 (PM) - - - - - {/* 3. 시(Hour) 선택 */} - - {parsedStartTime.ampm === '오전' ? '오전 시간 (시)' : '오후 시간 (시)'} - - - {(parsedStartTime.ampm === '오전' ? MORNING_HOURS : AFTERNOON_HOURS).map((h) => { - const isSelected = parsedStartTime.hour24 === h.hour24 && !parsedStartTime.isEmpty; - return ( - handleStartTimeHourChange(h.hour24)} - > - - {h.label} - - - ); - })} - - - {/* 4. 분(Minute) 선택 */} - 분 (Min) - - {MINUTE_OPTIONS.map((minStr) => { - const isSelected = parsedStartTime.minute === minStr && !parsedStartTime.isEmpty; - return ( - handleStartTimeMinuteChange(minStr)} - > - - {minStr}분 - - - ); - })} - - - {/* 확인 완료 버튼 */} - setTimePickerVisible(false)} - > - - - {formStartTime ? `${formStartTime} 선택 완료` : '선택 완료'} - - - - - - + time={formStartTime} + title="시작 시간 선택" + onSelectTime={setFormStartTime} + onClose={() => setTimePickerVisible(false)} + /> ); } @@ -2945,145 +2756,4 @@ const styles = StyleSheet.create({ fontWeight: '700', color: theme.colors.textPrimary, }, - timePickerModalContent: { - backgroundColor: theme.colors.white, - borderTopLeftRadius: 24, - borderTopRightRadius: 24, - padding: 20, - maxHeight: '85%', - }, - timeModalTitleRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }, - currentTimeDisplayBox: { - backgroundColor: theme.colors.secondaryContainer, - borderRadius: 12, - paddingVertical: 12, - alignItems: 'center', - marginBottom: 14, - borderWidth: 1, - borderColor: theme.colors.primary + '30', - }, - currentTimeDisplayText: { - fontSize: 28, - fontWeight: '800', - color: theme.colors.primary, - letterSpacing: 2, - }, - currentTimeDisplaySub: { - fontSize: 12.5, - fontWeight: '600', - color: theme.colors.textSecondary, - marginTop: 2, - }, - timePickerSectionTitle: { - fontSize: 12, - fontWeight: '700', - color: theme.colors.textSecondary, - marginBottom: 6, - marginTop: 8, - }, - quickPresetScroll: { - paddingVertical: 2, - marginBottom: 10, - }, - quickPresetChip: { - paddingHorizontal: 12, - paddingVertical: 6, - borderRadius: 16, - backgroundColor: theme.colors.surfaceVariant, - marginRight: 6, - borderWidth: 1, - borderColor: theme.colors.outline + '40', - }, - quickPresetChipActive: { - backgroundColor: theme.colors.primary, - borderColor: theme.colors.primary, - }, - quickPresetText: { - fontSize: 12.5, - color: theme.colors.textSecondary, - fontWeight: '600', - }, - quickPresetTextActive: { - color: theme.colors.onPrimary, - fontWeight: 'bold', - }, - timeAmpmRow: { - flexDirection: 'row', - backgroundColor: theme.colors.surfaceVariant, - borderRadius: 8, - padding: 3, - marginBottom: 8, - marginTop: 4, - }, - timeAmpmTab: { - flex: 1, - paddingVertical: 7, - alignItems: 'center', - borderRadius: 6, - }, - timeAmpmTabActive: { - backgroundColor: theme.colors.white, - shadowColor: '#000', - shadowOffset: { width: 0, height: 1 }, - shadowOpacity: 0.1, - elevation: 2, - }, - timeAmpmTabText: { - fontSize: 13, - color: theme.colors.textSecondary, - fontWeight: '600', - }, - timeAmpmTabTextActive: { - color: theme.colors.primary, - fontWeight: 'bold', - }, - timeGridRow: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 6, - marginBottom: 10, - }, - timeGridBtn: { - flexBasis: '22.5%', - flexGrow: 1, - paddingVertical: 8, - borderRadius: 8, - alignItems: 'center', - backgroundColor: theme.colors.surfaceVariant + '80', - borderWidth: 1, - borderColor: theme.colors.outline + '40', - }, - timeGridBtnActive: { - backgroundColor: theme.colors.secondaryContainer, - borderColor: theme.colors.primary, - }, - timeGridBtnText: { - fontSize: 12, - color: theme.colors.textPrimary, - fontWeight: '500', - }, - timeGridBtnTextActive: { - color: theme.colors.onSecondaryContainer, - fontWeight: 'bold', - }, - timeConfirmBtn: { - flexDirection: 'row', - justifyContent: 'center', - alignItems: 'center', - gap: 6, - marginTop: 12, - marginBottom: 16, - backgroundColor: theme.colors.primary, - paddingVertical: 12, - borderRadius: 10, - }, - timeConfirmBtnText: { - color: theme.colors.onPrimary, - fontSize: 14, - fontWeight: 'bold', - }, }); From 17acfc56a06cd481e028e411f4bca9f544478b45 Mon Sep 17 00:00:00 2001 From: sbjwin Date: Sun, 1 Nov 2026 23:21:30 +0900 Subject: [PATCH 2/3] =?UTF-8?q?=EB=AC=B8=EC=84=9C:=20PR=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=ED=95=A8=EC=88=98=20=EB=B0=8F=20=EC=BB=B4=ED=8F=AC?= =?UTF-8?q?=EB=84=8C=ED=8A=B8=20JSDoc=20=EC=A3=BC=EC=84=9D=20=EC=BB=A4?= =?UTF-8?q?=EB=B2=84=EB=A6=AC=EC=A7=80=20100%=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- App.js | 6 ++++++ src/components/modals/CallManageModal.js | 11 ++++++++++- src/components/modals/StudentStatusModal.js | 11 +++++++++++ src/components/modals/TimePickerModal.js | 21 ++++++++++++++++++++- src/screens/CalendarScreen.js | 4 ++++ src/screens/StudentDetailScreen.js | 4 ++++ src/screens/WeeklyPlanScreen.js | 4 ++++ 7 files changed, 59 insertions(+), 2 deletions(-) diff --git a/App.js b/App.js index b7397a8..7b47743 100644 --- a/App.js +++ b/App.js @@ -19,6 +19,12 @@ import { ThemeProvider, useTheme } from './src/context/ThemeContext'; const Stack = createStackNavigator(); const Tab = createBottomTabNavigator(); +/** + * 하단 5대 메인 탭 네비게이터 (홈, 주간시간표, 달력일지, 학생, 설정) + * @param {object} props - 컴포넌트 속성 + * @param {object} props.navigation - React Navigation 네비게이션 객체 + * @returns {React.JSX.Element} 메인 하단 탭 네비게이터 요소 + */ function MainTabs({ navigation }) { const { theme } = useTheme(); diff --git a/src/components/modals/CallManageModal.js b/src/components/modals/CallManageModal.js index c76f853..b5553a0 100644 --- a/src/components/modals/CallManageModal.js +++ b/src/components/modals/CallManageModal.js @@ -15,7 +15,16 @@ import { useTheme } from '../../context/ThemeContext'; import { theme as defaultTheme } from '../../theme'; /** - * 주간 시간표 전화관리 상담 항목 추가 모달 + * 주간 시간표 전화관리 상담 항목 추가 모달 컴포넌트 + * @param {object} props - 모달 컴포넌트 속성 + * @param {boolean} props.visible - 모달 표시 여부 + * @param {() => void} props.onClose - 모달 닫기 콜백 함수 + * @param {string} props.callName - 상담 대상 회원 이름 입력값 + * @param {(text: string) => void} props.onChangeCallName - 회원 이름 변경 핸들러 + * @param {string} props.callContent - 통화 요일 및 상담 내용 입력값 + * @param {(text: string) => void} props.onChangeCallContent - 상담 내용 변경 핸들러 + * @param {() => void} props.onAdd - 상담 항목 추가 확정 콜백 함수 + * @returns {React.JSX.Element} 전화관리 상담 추가 모달 요소 */ export default function CallManageModal({ visible, diff --git a/src/components/modals/StudentStatusModal.js b/src/components/modals/StudentStatusModal.js index 2165b82..1c85f98 100644 --- a/src/components/modals/StudentStatusModal.js +++ b/src/components/modals/StudentStatusModal.js @@ -18,6 +18,17 @@ import { theme as defaultTheme } from '../../theme'; /** * 학생 수강 상태(휴회 / 재수강 복귀)를 전환하는 공통 모달 컴포넌트 + * @param {object} props - 모달 컴포넌트 속성 + * @param {boolean} props.visible - 모달 표시 여부 + * @param {'pause'|'resume'} [props.type='pause'] - 상태 전환 유형 ('pause': 휴회, 'resume': 재수강 복귀) + * @param {string} [props.studentName=''] - 대상 학생 이름 + * @param {string} [props.date=''] - 선택된 기준 날짜 (YYYY-MM-DD 포맷) + * @param {(dateStr: string) => void} props.onDateChange - 날짜 변경 콜백 함수 + * @param {string} [props.reason=''] - 휴회 또는 재등록 사유 입력값 + * @param {(text: string) => void} props.onReasonChange - 사유 변경 콜백 함수 + * @param {() => void} props.onConfirm - 상태 변경 확정 콜백 함수 + * @param {() => void} props.onClose - 모달 닫기 콜백 함수 + * @returns {React.JSX.Element} 학생 상태 전환 모달 요소 */ export default function StudentStatusModal({ visible, diff --git a/src/components/modals/TimePickerModal.js b/src/components/modals/TimePickerModal.js index 7bb1482..947c0d0 100644 --- a/src/components/modals/TimePickerModal.js +++ b/src/components/modals/TimePickerModal.js @@ -58,7 +58,14 @@ export const QUICK_TIME_PRESETS = [ ]; /** - * 시작 시간 GUI 선택 공통 팝업 모달 + * 시작 시간 GUI 선택 공통 팝업 모달 컴포넌트 + * @param {object} props - 모달 컴포넌트 속성 + * @param {boolean} props.visible - 모달 표시 여부 + * @param {string} [props.time='10:00'] - 현재 선택된 시간 문자열 (HH:mm 포맷) + * @param {string} [props.title='시작 시간 선택'] - 모달 상단 제목 + * @param {(newTime: string) => void} props.onSelectTime - 시간 선택 시 호출되는 콜백 함수 + * @param {() => void} props.onClose - 모달 닫기 콜백 함수 + * @returns {React.JSX.Element} 시간 선택 모달 요소 */ export default function TimePickerModal({ visible, @@ -88,6 +95,10 @@ export default function TimePickerModal({ return { ampm: '오전', hour24: 10, minute: '00', isEmpty: false }; }, [time]); + /** + * 오전/오후(AM/PM) 전환 시 24시간제 시간을 계산하여 반영합니다. + * @param {'오전'|'오후'} newAmpm - 변경할 오전/오후 구분값 + */ const handleAmpmChange = (newAmpm) => { let targetHour = parsedTime.hour24; if (newAmpm === '오전' && targetHour >= 12) { @@ -99,11 +110,19 @@ export default function TimePickerModal({ onSelectTime(`${String(targetHour).padStart(2, '0')}:${m}`); }; + /** + * 시(Hour) 선택 시 현재 분(Minute)과 결합하여 HH:mm 포맷으로 반영합니다. + * @param {number} h24 - 24시간제 기준 시(0~23) + */ const handleHourChange = (h24) => { const m = parsedTime.minute || '00'; onSelectTime(`${String(h24).padStart(2, '0')}:${m}`); }; + /** + * 분(Minute) 선택 시 현재 시(Hour)와 결합하여 HH:mm 포맷으로 반영합니다. + * @param {string} mStr - 두 자리 분 문자열 ('00' ~ '55') + */ const handleMinuteChange = (mStr) => { const h24 = parsedTime.hour24; onSelectTime(`${String(h24).padStart(2, '0')}:${mStr}`); diff --git a/src/screens/CalendarScreen.js b/src/screens/CalendarScreen.js index 9d699b8..0c67261 100644 --- a/src/screens/CalendarScreen.js +++ b/src/screens/CalendarScreen.js @@ -68,6 +68,10 @@ const CustomDay = React.memo(({ date, state, marking, onPress }) => { ); }); +/** + * 월간/주간 달력 기반으로 일별 수업 일지를 조회하고 관리하는 화면 컴포넌트 + * @returns {React.JSX.Element} 달력 일지 화면 요소 + */ export default function CalendarScreen() { // eslint-disable-next-line no-shadow const { theme } = useTheme(); diff --git a/src/screens/StudentDetailScreen.js b/src/screens/StudentDetailScreen.js index da46ac2..eed9267 100644 --- a/src/screens/StudentDetailScreen.js +++ b/src/screens/StudentDetailScreen.js @@ -27,6 +27,10 @@ import StudentStatusModal from '../components/modals/StudentStatusModal'; import TimePickerModal from '../components/modals/TimePickerModal'; import ActionMenuItem from '../components/ActionMenuItem'; +/** + * 학생 개인 프로필, 수강 상태(재원/휴회), 정규 수업 일정 및 특이사항을 등록·수정하는 화면 컴포넌트 + * @returns {React.JSX.Element} 학생 상세 정보 화면 요소 + */ export default function StudentDetailScreen() { // eslint-disable-next-line no-shadow const { theme } = useTheme(); diff --git a/src/screens/WeeklyPlanScreen.js b/src/screens/WeeklyPlanScreen.js index 954ef38..d210c6e 100644 --- a/src/screens/WeeklyPlanScreen.js +++ b/src/screens/WeeklyPlanScreen.js @@ -45,6 +45,10 @@ const TIME_SLOTS = [ { label: '8시', hour: 20 }, ]; +/** + * 주간 시간표(전체 표 및 요일별 타임라인) 조회, 수업 일정 추가·편집, 전화관리 및 부가 업무를 수행하는 화면 컴포넌트 + * @returns {React.JSX.Element} 주간 시간표 화면 요소 + */ export default function WeeklyPlanScreen() { // eslint-disable-next-line no-shadow const { theme } = useTheme(); From 21b691fdd3b80510600a557a50a3ddf61e4fd1da Mon Sep 17 00:00:00 2001 From: sbjwin Date: Sun, 1 Nov 2026 23:36:58 +0900 Subject: [PATCH 3/3] =?UTF-8?q?=EC=88=98=EC=A0=95:=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=A6=AC=EB=B7=B0=20=ED=94=BC=EB=93=9C=EB=B0=B1=206=EA=B1=B4(1?= =?UTF-8?q?2=EC=8B=9C=20=EC=98=A4=ED=9B=84=20=EC=9D=B4=EB=8F=99,=20?= =?UTF-8?q?=EB=8B=AC=EB=A0=A5=20=EC=84=A0=ED=83=9D=20=EA=B0=95=EC=A1=B0,?= =?UTF-8?q?=20=ED=84=B0=EC=B9=98=20=EC=98=81=EC=97=AD=2044dp=20=ED=99=95?= =?UTF-8?q?=EB=B3=B4=20=EB=93=B1)=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- log/devlog.md | 6 ++--- src/components/modals/StudentStatusModal.js | 1 + src/components/modals/TimePickerModal.js | 26 +++++++++++++++++---- src/screens/StudentDetailScreen.js | 3 +++ src/screens/WeeklyPlanScreen.js | 2 ++ 5 files changed, 30 insertions(+), 8 deletions(-) diff --git a/log/devlog.md b/log/devlog.md index 3afa5fb..ce772d9 100644 --- a/log/devlog.md +++ b/log/devlog.md @@ -8,7 +8,7 @@ ## 2026-11-01: 전체 화면/모달 키보드 가림 방지 및 날짜·시간 피커 / 주간표 다중 학생 터치 UX 개선 (by Antigravity) * **반영 내역 (What landed)**: - * **공통 시간 선택 모달([src/components/modals/TimePickerModal.js](file:///d:/development/mydiary/src/components/modals/TimePickerModal.js)) 분리 신설**: + * **공통 시간 선택 모달([src/components/modals/TimePickerModal.js](../src/components/modals/TimePickerModal.js)) 분리 신설**: * 주간 시간표에 내장되어 있던 시작 시간 GUI 선택 팝업(빠른 프리셋, 오전/오후 탭, 시/분 그리드)을 재사용 가능한 공통 모달로 모듈화하여 [WeeklyPlanScreen.js](file:///d:/development/mydiary/src/screens/WeeklyPlanScreen.js) 라인 수를 3,089줄에서 2,760줄로 329줄 감축. * **1번 과제 — 키보드 가림 현상 5곳 및 하단 탭바 자동 숨김 완비**: * [App.js](file:///d:/development/mydiary/App.js): 하단 탭 네비게이터에 `tabBarHideOnKeyboard: true`를 적용하여 키보드 노출 시 하단 탭바가 입력 영역을 침범하지 않도록 개선. @@ -17,11 +17,11 @@ * [src/screens/CalendarScreen.js](file:///d:/development/mydiary/src/screens/CalendarScreen.js): 학생 선택 검색 모달에서 키보드 노출 시 모달을 화면 상단으로 배치하고 키보드 위쪽까지 가용 높이를 동적 계산하도록 개선. * [src/components/modals/CallManageModal.js](file:///d:/development/mydiary/src/components/modals/CallManageModal.js) & [src/components/modals/StudentStatusModal.js](file:///d:/development/mydiary/src/components/modals/StudentStatusModal.js): 중앙 정렬 다이얼로그가 안드로이드 키보드에 가려지지 않도록 키보드 노출 시 상단 배치 전환. * **2번 과제 — 날짜/시간 GUI 피커 일원화 및 주간 전체 표 다중 학생 터치 개선**: - * [src/screens/StudentDetailScreen.js](file:///d:/development/mydiary/src/screens/StudentDetailScreen.js): 수동 타이핑하던 '최초 수강 시작일(입회일)'에 인라인 달력(`Calendar`) 피커를 적용하고, '정규 수업 일정 시작 시간'에 공통 [`TimePickerModal`](file:///d:/development/mydiary/src/components/modals/TimePickerModal.js)을 연동하여 오타 원천 방지. + * [src/screens/StudentDetailScreen.js](file:///d:/development/mydiary/src/screens/StudentDetailScreen.js): 수동 타이핑하던 '최초 수강 시작일(입회일)'에 인라인 달력(`Calendar`) 피커를 적용하고, '정규 수업 일정 시작 시간'에 공통 [`TimePickerModal`](../src/components/modals/TimePickerModal.js)을 연동하여 오타 원천 방지. * [src/components/modals/StudentStatusModal.js](file:///d:/development/mydiary/src/components/modals/StudentStatusModal.js): 휴회 종료일 및 재수강 시작일 입력란을 인라인 달력(`Calendar`) 피커로 전환. * [src/screens/WeeklyPlanScreen.js](file:///d:/development/mydiary/src/screens/WeeklyPlanScreen.js): 주간 전체 표(Table 뷰)의 동일 시간대 셀에 2명 이상 학생이 배정된 경우 각 학생 카드(`cellItemBox`)를 개별 터치하여 해당 학생의 일정을 바로 편집할 수 있도록 분리하고, 셀 빈 영역 터치 시 새 수업 추가 모달이 열리도록 개선. * **단위 테스트 추가**: - * [__tests__/Components.test.js](file:///d:/development/mydiary/__tests__/Components.test.js): [`TimePickerModal`](file:///d:/development/mydiary/src/components/modals/TimePickerModal.js) 렌더링 검증 테스트 추가 (총 31개 테스트). + * [__tests__/Components.test.js](file:///d:/development/mydiary/__tests__/Components.test.js): [`TimePickerModal`](../src/components/modals/TimePickerModal.js) 렌더링 검증 테스트 추가 (총 31개 테스트). * **검증 수치 (Numbers)**: * 올인원 품질 게이트 (`npm run gate`): 100% 통과 diff --git a/src/components/modals/StudentStatusModal.js b/src/components/modals/StudentStatusModal.js index 1c85f98..206e367 100644 --- a/src/components/modals/StudentStatusModal.js +++ b/src/components/modals/StudentStatusModal.js @@ -157,6 +157,7 @@ export default function StudentStatusModal({ { onDateChange(day.dateString); setShowDatePicker(false); diff --git a/src/components/modals/TimePickerModal.js b/src/components/modals/TimePickerModal.js index 947c0d0..698b346 100644 --- a/src/components/modals/TimePickerModal.js +++ b/src/components/modals/TimePickerModal.js @@ -18,10 +18,10 @@ export const MORNING_HOURS = [ { hour24: 9, label: '09시' }, { hour24: 10, label: '10시' }, { hour24: 11, label: '11시' }, - { hour24: 12, label: '12시' }, ]; export const AFTERNOON_HOURS = [ + { hour24: 12, label: '12시' }, { hour24: 13, label: '13시 (1시)' }, { hour24: 14, label: '14시 (2시)' }, { hour24: 15, label: '15시 (3시)' }, @@ -97,15 +97,31 @@ export default function TimePickerModal({ /** * 오전/오후(AM/PM) 전환 시 24시간제 시간을 계산하여 반영합니다. + * 변환된 시각이 대상 오전/오후 목록에 없을 경우에만 기본 시각으로 대체하며, + * 이미 선택된 탭을 다시 눌렀을 때 유효한 현재 시각은 그대로 유지합니다. * @param {'오전'|'오후'} newAmpm - 변경할 오전/오후 구분값 */ const handleAmpmChange = (newAmpm) => { + const targetHours = newAmpm === '오전' ? MORNING_HOURS : AFTERNOON_HOURS; + const defaultHour = newAmpm === '오전' ? 10 : 14; let targetHour = parsedTime.hour24; - if (newAmpm === '오전' && targetHour >= 12) { - targetHour = targetHour === 12 ? 0 : targetHour - 12; - } else if (newAmpm === '오후' && targetHour < 12) { - targetHour = targetHour === 0 ? 12 : targetHour + 12; + + if (parsedTime.ampm === newAmpm) { + if (targetHours.some((h) => h.hour24 === targetHour)) { + return; + } + targetHour = defaultHour; + } else { + if (newAmpm === '오전' && targetHour >= 12) { + targetHour = targetHour === 12 ? 0 : targetHour - 12; + } else if (newAmpm === '오후' && targetHour < 12) { + targetHour = targetHour === 0 ? 12 : targetHour + 12; + } + if (!targetHours.some((h) => h.hour24 === targetHour)) { + targetHour = defaultHour; + } } + const m = parsedTime.minute || '00'; onSelectTime(`${String(targetHour).padStart(2, '0')}:${m}`); }; diff --git a/src/screens/StudentDetailScreen.js b/src/screens/StudentDetailScreen.js index eed9267..47b5775 100644 --- a/src/screens/StudentDetailScreen.js +++ b/src/screens/StudentDetailScreen.js @@ -483,6 +483,7 @@ export default function StudentDetailScreen() { { setFirstEnrolledDate(day.dateString); setShowEnrolledDatePicker(false); @@ -779,6 +780,7 @@ export default function StudentDetailScreen() { value={sched.subject || ''} onChangeText={(val) => handleUpdateDefaultSchedule(sched.id, 'subject', val)} onFocus={() => { + if (idx !== defaultSchedules.length - 1) return; setTimeout(() => { scrollRef.current?.scrollToEnd({ animated: true }); }, 150); @@ -1350,6 +1352,7 @@ const styles = StyleSheet.create({ color: theme.colors.textPrimary, }, schedTimeSelectorBtn: { + minHeight: 44, flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', diff --git a/src/screens/WeeklyPlanScreen.js b/src/screens/WeeklyPlanScreen.js index d210c6e..5ea208f 100644 --- a/src/screens/WeeklyPlanScreen.js +++ b/src/screens/WeeklyPlanScreen.js @@ -1801,6 +1801,8 @@ const styles = StyleSheet.create({ color: '#92400E', }, cellItemBox: { + minHeight: 44, + justifyContent: 'center', backgroundColor: '#EFF6FF', borderRadius: 4, padding: 3,