diff --git a/App.js b/App.js
index 3e1a074..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();
@@ -45,6 +51,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..ce772d9 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](../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`](../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`](../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..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,
@@ -29,15 +38,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 +69,18 @@ export default function CallManageModal({
transparent={true}
visible={visible}
onRequestClose={onClose}
+ statusBarTranslucent
>
{
if (isKeyboardVisible) {
@@ -134,6 +157,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..206e367 100644
--- a/src/components/modals/StudentStatusModal.js
+++ b/src/components/modals/StudentStatusModal.js
@@ -1,21 +1,34 @@
-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';
/**
* 학생 수강 상태(휴회 / 재수강 복귀)를 전환하는 공통 모달 컴포넌트
+ * @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,
@@ -31,6 +44,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 +92,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 +220,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 +267,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..698b346
--- /dev/null
+++ b/src/components/modals/TimePickerModal.js
@@ -0,0 +1,499 @@
+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시' },
+];
+
+export const AFTERNOON_HOURS = [
+ { hour24: 12, label: '12시' },
+ { 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 선택 공통 팝업 모달 컴포넌트
+ * @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,
+ 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]);
+
+ /**
+ * 오전/오후(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 (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}`);
+ };
+
+ /**
+ * 시(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}`);
+ };
+
+ 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..0c67261 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';
@@ -66,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();
@@ -79,6 +85,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 +532,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 +813,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 +828,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..47b5775 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,8 +24,13 @@ 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';
+/**
+ * 학생 개인 프로필, 수강 상태(재원/휴회), 정규 수업 일정 및 특이사항을 등록·수정하는 화면 컴포넌트
+ * @returns {React.JSX.Element} 학생 상세 정보 화면 요소
+ */
export default function StudentDetailScreen() {
// eslint-disable-next-line no-shadow
const { theme } = useTheme();
@@ -72,6 +79,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 +388,15 @@ export default function StudentDetailScreen() {
return (
-
+
{/* 수강 상태 및 차수(Terms) 이력 */}
@@ -400,16 +455,47 @@ 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 +760,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 +779,12 @@ export default function StudentDetailScreen() {
style={styles.schedInput}
value={sched.subject || ''}
onChangeText={(val) => handleUpdateDefaultSchedule(sched.id, 'subject', val)}
+ onFocus={() => {
+ if (idx !== defaultSchedules.length - 1) return;
+ setTimeout(() => {
+ scrollRef.current?.scrollToEnd({ animated: true });
+ }, 150);
+ }}
placeholder="예: 파이썬, C언어, 자바"
placeholderTextColor={theme.colors.outline}
/>
@@ -709,6 +806,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 +834,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 +1330,7 @@ const styles = StyleSheet.create({
gap: 10,
},
schedInputColSmall: {
- width: 90,
+ width: 105,
},
schedInputColLarge: {
flex: 1,
@@ -1234,6 +1351,44 @@ const styles = StyleSheet.create({
fontSize: 13,
color: theme.colors.textPrimary,
},
+ schedTimeSelectorBtn: {
+ minHeight: 44,
+ 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..5ea208f 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,10 @@ 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',
-];
-
+/**
+ * 주간 시간표(전체 표 및 요일별 타임라인) 조회, 수업 일정 추가·편집, 전화관리 및 부가 업무를 수행하는 화면 컴포넌트
+ * @returns {React.JSX.Element} 주간 시간표 화면 요소
+ */
export default function WeeklyPlanScreen() {
// eslint-disable-next-line no-shadow
const { theme } = useTheme();
@@ -133,6 +91,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 +103,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 +115,7 @@ export default function WeeklyPlanScreen() {
() => {
setIsKeyboardVisible(false);
setKeyboardHeight(0);
+ focusedModalFieldRef.current = null;
}
);
return () => {
@@ -157,48 +124,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 +778,13 @@ export default function WeeklyPlanScreen() {
주간 시간표를 불러오는 중입니다...
) : (
-
+
{/* ======================= 보기 1: 주간 전체 타임테이블 (PDF 서식) ======================= */}
{viewMode === 'table' && (
@@ -891,19 +835,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 +863,7 @@ export default function WeeklyPlanScreen() {
{item.statusNote}
) : null}
-
+
);
})}
{items.length === 0 && (
@@ -1165,6 +1108,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 +1127,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 +1146,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 +1233,11 @@ export default function WeeklyPlanScreen() {
{/* 학생 선택 버튼 */}
@@ -1358,6 +1318,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 +1336,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 +1366,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 +1551,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)}
+ />
);
}
@@ -1986,6 +1801,8 @@ const styles = StyleSheet.create({
color: '#92400E',
},
cellItemBox: {
+ minHeight: 44,
+ justifyContent: 'center',
backgroundColor: '#EFF6FF',
borderRadius: 4,
padding: 3,
@@ -2945,145 +2762,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',
- },
});