diff --git a/__tests__/Components.test.js b/__tests__/Components.test.js index e1753fd..f31539e 100644 --- a/__tests__/Components.test.js +++ b/__tests__/Components.test.js @@ -7,6 +7,8 @@ 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'; +import RecordPrintModal from '../src/components/modals/RecordPrintModal'; +import ScheduleEditModal from '../src/components/modals/ScheduleEditModal'; describe('공통 컴포넌트 단위 테스트', () => { test('Separator 컴포넌트가 오류 없이 렌더링되어야 함', () => { @@ -124,5 +126,85 @@ describe('공통 컴포넌트 단위 테스트', () => { expect(visibleModal).toBeDefined(); expect(visibleModal.toJSON()).toBeTruthy(); }); + + test('RecordPrintModal 컴포넌트가 visible=false 및 true일 때 정상 렌더링되어야 함', () => { + let hiddenModal; + let studentModal; + let recordsModal; + const mockStudent = { id: 's1', name: '홍길동', school_grade: '중2' }; + const mockRecords = [{ id: 'r1', class_date: '2026-04-10', student_id: 's1' }]; + act(() => { + hiddenModal = renderer.create( + + ); + studentModal = renderer.create( + + ); + recordsModal = renderer.create( + + ); + }); + expect(hiddenModal).toBeDefined(); + expect(studentModal).toBeDefined(); + expect(studentModal.toJSON()).toBeTruthy(); + expect(recordsModal).toBeDefined(); + expect(recordsModal.toJSON()).toBeTruthy(); + }); + + test('ScheduleEditModal 컴포넌트가 visible=false 및 true일 때 정상 렌더링되어야 함', () => { + let hiddenModal; + let visibleModal; + const mockStudents = [ + { id: 's1', name: '홍길동', school_grade: '고1', mobile_phone: '010-1234-5678' }, + ]; + 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 7a6382a..99003d5 100644 --- a/log/devlog.md +++ b/log/devlog.md @@ -5,6 +5,37 @@ --- +## 2026-11-13: 전체 UI/UX 5단계 순차 개선 (동적 테마 반응성, 시간 피커 통합, 주간시간표·학생상세 UX, 공통 모달 2종 분리) (by Antigravity) + +* **반영 내역 (What landed)**: + * **Phase 1 (동적 테마 전환 반응성 복구)**: + * 8개 화면([HomeScreen.js](../src/screens/HomeScreen.js), [WeeklyPlanScreen.js](../src/screens/WeeklyPlanScreen.js), [CalendarScreen.js](../src/screens/CalendarScreen.js), [StudentListScreen.js](../src/screens/StudentListScreen.js), [SettingsScreen.js](../src/screens/SettingsScreen.js), [StudentDetailScreen.js](../src/screens/StudentDetailScreen.js), [ClassRecordScreen.js](../src/screens/ClassRecordScreen.js), [HelpScreen.js](../src/screens/HelpScreen.js)) 및 공용 컴포넌트([Separator.js](../src/components/Separator.js), [ConfirmModal.js](../src/components/ConfirmModal.js))의 정적 `StyleSheet.create`를 `useMemo(() => createStyles(theme), [theme])` 구조로 전환하여 설정 화면 프리셋 전환 시 즉시 반영되도록 개선. + * **Phase 2 (시간 피커 통합 및 오전 12시 버그 수정)**: + * [ClassRecordScreen.js](../src/screens/ClassRecordScreen.js)의 중복 인라인 시간 피커(~180줄)를 제거하고 공용 [TimePickerModal.js](../src/components/modals/TimePickerModal.js)로 통합. + * **Phase 3 (주간시간표 모바일 UX 강화)**: + * [WeeklyPlanScreen.js](../src/screens/WeeklyPlanScreen.js)의 타임라인 `selectedDayOffset` 초기값을 오늘 요일(월=0~일=6)로 자동 포커스하고, 표 헤더 및 타임라인 요일 탭에 오늘 날짜 강조(`dayHeaderCellToday`, `dayTabPillToday`) 적용. + * 주간 표 열 너비(`68 -> 76`, `tableInner.minWidth: 500`) 및 셀 폰트(`cellItemTitle: 10.5`, `cellItemSubject: 9.5`, `cellItemNote: 9`) 확대. + * `handleMakeCall`이 다중 연락처 매칭 시 `Alert.alert('통화할 번호 선택', ...)` 선택창을 표시하도록 개선. + * **Phase 4 (학생 상세 입력 안정성 및 오프라인 배너)**: + * [StudentDetailScreen.js](../src/screens/StudentDetailScreen.js)에 `beforeRemove` + `ConfirmModal` 기반 미저장 변경사항 이탈 방지 가드 추가(저장/삭제 완료 및 휴회/재수강 즉시 저장 시 정상 이탈 허용). + * 외부 URL `ImageBackground` 배너를 오프라인 테마 반응형 배너 카드(`styles.bannerCard`)로 교체하고, 신규 등록 모드에서도 초기 수강 상태(`active`/`paused`) 전환 지원. + * **Phase 5 (공통 모달 분리 및 단위 테스트)**: + * [RecordPrintModal.js](../src/components/modals/RecordPrintModal.js) 신규 분리 후 [StudentListScreen.js](../src/screens/StudentListScreen.js), [ClassRecordScreen.js](../src/screens/ClassRecordScreen.js), [StudentDetailScreen.js](../src/screens/StudentDetailScreen.js) 연동. + * [ScheduleEditModal.js](../src/components/modals/ScheduleEditModal.js) 신규 분리(이름·학교·전화번호 확장 검색 포함) 후 [WeeklyPlanScreen.js](../src/screens/WeeklyPlanScreen.js) 경량화(2,765줄 -> 2,097줄). + * [__tests__/Components.test.js](../__tests__/Components.test.js)에 `RecordPrintModal`, `ScheduleEditModal` 렌더링 단위 테스트 2건 추가. + +* **검증 수치 (Numbers)**: + * 올인원 품질 게이트 (`cmd /c npm run gate`): 100% 통과 + * ESLint 무결성: 에러 0건, 경고 0건 + * Jest 단위 테스트: 6개 스위트, 33개 테스트 전체 통과 (100% PASS) + * 정기 프로젝트 건전성 정밀 감사: **100점 / 100점** 달성 (초거대 위험 파일 0건, 공통 컴포넌트 11개) + +* **미해결 과제 (Still open)**: + * [docs/gaps.md](file:///d:/development/mydiary/docs/gaps.md)의 `[G1]` HWPX 복수 표 서식 레이아웃 프리셋(표준형, 서술형, 결재란 포함형) 다변화 작업 대기 중. + * [StudentDetailScreen.js](../src/screens/StudentDetailScreen.js)(1,587줄) 및 [WeeklyPlanScreen.js](../src/screens/WeeklyPlanScreen.js)(2,097줄) 추가 세부 섹션 분리 선택 사항. + +--- + ## 2026-11-02: v2.5.0 공식 마이너 릴리즈 마일스톤 버전 5개 파일 동기화 및 릴리즈 태깅 (by Antigravity) * **반영 내역 (What landed)**: diff --git a/src/components/ConfirmModal.js b/src/components/ConfirmModal.js index d0962ea..912dead 100644 --- a/src/components/ConfirmModal.js +++ b/src/components/ConfirmModal.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useMemo } from 'react'; import { Modal, View, @@ -8,7 +8,8 @@ import { ActivityIndicator, } from 'react-native'; import { Feather } from '@expo/vector-icons'; -import { theme } from '../theme'; +import { useTheme } from '../context/ThemeContext'; +import { theme as defaultTheme } from '../theme'; /** * 앱 전반에서 일관된 UX를 제공하는 공통 확인/삭제 모달 컴포넌트 @@ -25,6 +26,10 @@ const ConfirmModal = ({ loading = false, iconName = isDestructive ? 'alert-triangle' : 'help-circle', }) => { + const { theme: activeTheme } = useTheme(); + const theme = activeTheme || defaultTheme; + const styles = useMemo(() => createStyles(theme), [theme]); + return ( StyleSheet.create({ overlay: { flex: 1, backgroundColor: 'rgba(0, 0, 0, 0.5)', diff --git a/src/components/Separator.js b/src/components/Separator.js index 9425b90..2677ec5 100644 --- a/src/components/Separator.js +++ b/src/components/Separator.js @@ -1,12 +1,24 @@ import React from 'react'; import { View, StyleSheet } from 'react-native'; -import { theme } from '../theme'; +import { useTheme } from '../context/ThemeContext'; +import { theme as defaultTheme } from '../theme'; /** * 리스트 아이템 사이 또는 섹션 구분을 위한 공통 구분선 컴포넌트 */ const Separator = React.memo(({ style }) => { - return ; + const { theme: activeTheme } = useTheme(); + const theme = activeTheme || defaultTheme; + + return ( + + ); }); Separator.displayName = 'Separator'; @@ -14,7 +26,6 @@ Separator.displayName = 'Separator'; const styles = StyleSheet.create({ separator: { height: 1, - backgroundColor: theme.colors.outline || '#E2E8F0', }, }); diff --git a/src/components/modals/RecordPrintModal.js b/src/components/modals/RecordPrintModal.js new file mode 100644 index 0000000..7b13db9 --- /dev/null +++ b/src/components/modals/RecordPrintModal.js @@ -0,0 +1,472 @@ +import React, { useState, useEffect, useMemo } from 'react'; +import { + Modal, + View, + Text, + TouchableOpacity, + ScrollView, + StyleSheet, +} 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'; +import { getTodayFormatted } from '../../utils/dateUtils'; +import ActionMenuItem from '../ActionMenuItem'; + +/** + * 학생 카드 및 수업 일지 기간별 출력(프린터 인쇄 / PDF 공유) 공통 모달 컴포넌트 + * @param {object} props - 모달 속성 + * @param {boolean} props.visible - 모달 표시 여부 + * @param {() => void} props.onClose - 닫기 콜백 + * @param {'student'|'records'} [props.printType='records'] - 출력 유형 ('student': 학생 카드, 'records': 수업 일지) + * @param {object|null} [props.student] - 대상 학생 객체 + * @param {Array} [props.records=[]] - 대상 학생의 전체 수업 일지 배열 + * @param {(student: object) => void} [props.onPrintStudent] - 학생 카드 인쇄 콜백 + * @param {(student: object) => void} [props.onShareStudent] - 학생 카드 PDF 공유 콜백 + * @param {(student: object, filteredRecords: Array, periodTitle: string) => void} [props.onPrintRecords] - 수업 일지 인쇄 콜백 + * @param {(student: object, filteredRecords: Array, periodTitle: string) => void} [props.onShareRecords] - 수업 일지 PDF 공유 콜백 + * @returns {React.JSX.Element} 출력 선택 모달 요소 + */ +export default function RecordPrintModal({ + visible, + onClose, + printType = 'records', + student, + records = [], + onPrintStudent, + onShareStudent, + onPrintRecords, + onShareRecords, +}) { + const { theme: activeTheme } = useTheme(); + const theme = activeTheme || defaultTheme; + const styles = useMemo(() => createStyles(theme), [theme]); + + const [periodFilter, setPeriodFilter] = useState('all'); + const [customStartDate, setCustomStartDate] = useState(() => { + const d = new Date(); + d.setMonth(d.getMonth() - 1); + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + }); + const [customEndDate, setCustomEndDate] = useState(() => getTodayFormatted()); + const [activeDatePicker, setActiveDatePicker] = useState(null); // 'start' | 'end' | null + + useEffect(() => { + if (visible) { + setPeriodFilter('all'); + setActiveDatePicker(null); + } + }, [visible]); + + const { filteredRecordsForPrint, periodTitle } = useMemo(() => { + if (!records || !records.length) { + return { filteredRecordsForPrint: [], periodTitle: '전체 기간' }; + } + + const today = new Date(); + const todayStr = getTodayFormatted(); + + if (periodFilter === 'custom') { + const start = customStartDate <= customEndDate ? customStartDate : customEndDate; + const end = customStartDate <= customEndDate ? customEndDate : customStartDate; + const filtered = records.filter((r) => r.class_date >= start && r.class_date <= end); + return { + filteredRecordsForPrint: filtered, + periodTitle: `지정 기간 (${start} ~ ${end})`, + }; + } + + if (periodFilter === '1m') { + const oneMonthAgo = new Date(); + oneMonthAgo.setMonth(today.getMonth() - 1); + const limitStr = `${oneMonthAgo.getFullYear()}-${String(oneMonthAgo.getMonth() + 1).padStart(2, '0')}-${String(oneMonthAgo.getDate()).padStart(2, '0')}`; + const filtered = records.filter((r) => r.class_date >= limitStr); + return { + filteredRecordsForPrint: filtered, + periodTitle: `최근 1개월 (${limitStr} ~ ${todayStr})`, + }; + } + + if (periodFilter === '3m') { + const threeMonthsAgo = new Date(); + threeMonthsAgo.setMonth(today.getMonth() - 3); + const limitStr = `${threeMonthsAgo.getFullYear()}-${String(threeMonthsAgo.getMonth() + 1).padStart(2, '0')}-${String(threeMonthsAgo.getDate()).padStart(2, '0')}`; + const filtered = records.filter((r) => r.class_date >= limitStr); + return { + filteredRecordsForPrint: filtered, + periodTitle: `최근 3개월 (${limitStr} ~ ${todayStr})`, + }; + } + + if (periodFilter === 'current_month') { + const yearMonth = todayStr.substring(0, 7); + const filtered = records.filter((r) => r.class_date && r.class_date.startsWith(yearMonth)); + return { + filteredRecordsForPrint: filtered, + periodTitle: `${today.getFullYear()}년 ${today.getMonth() + 1}월`, + }; + } + + const sorted = [...records].sort((a, b) => a.class_date.localeCompare(b.class_date)); + const start = sorted[0]?.class_date || todayStr; + const end = sorted[sorted.length - 1]?.class_date || todayStr; + return { + filteredRecordsForPrint: records, + periodTitle: `전체 기간 (${start} ~ ${end})`, + }; + }, [records, periodFilter, customStartDate, customEndDate]); + + const handlePrint = () => { + onClose(); + if (printType === 'student') { + if (onPrintStudent) onPrintStudent(student); + } else if (onPrintRecords) { + onPrintRecords(student, filteredRecordsForPrint, periodTitle); + } + }; + + const handleShare = () => { + onClose(); + if (printType === 'student') { + if (onShareStudent) onShareStudent(student); + } else if (onShareRecords) { + onShareRecords(student, filteredRecordsForPrint, periodTitle); + } + }; + + const studentName = student?.name || '학생'; + + return ( + + + e.stopPropagation()} + > + + + + + {printType === 'student' ? '학생 정보 카드 출력' : '수업 일지 보고서 출력'} + + + {printType === 'student' + ? `${studentName} 학생의 인적사항 양식` + : `${studentName} 학생의 수업 기록 (${filteredRecordsForPrint.length}건 선택됨)`} + + + + + + + + {printType === 'records' && ( + <> + 출력 기간 선택 + + {[ + { id: 'all', label: '전체 기간' }, + { id: 'current_month', label: '이번 달' }, + { id: '1m', label: '최근 1개월' }, + { id: '3m', label: '최근 3개월' }, + { id: 'custom', label: '직접 설정' }, + ].map((chip) => { + const isActive = periodFilter === chip.id; + return ( + { + setPeriodFilter(chip.id); + setActiveDatePicker(null); + }} + > + + {chip.label} + + + ); + })} + + + {periodFilter === 'custom' && ( + + + setActiveDatePicker(activeDatePicker === 'start' ? null : 'start')} + > + 시작일 + {customStartDate} + + + ~ + + setActiveDatePicker(activeDatePicker === 'end' ? null : 'end')} + > + 종료일 + {customEndDate} + + + + {activeDatePicker && ( + + + + {activeDatePicker === 'start' ? '시작 날짜 선택' : '종료 날짜 선택'} + + + { + if (activeDatePicker === 'start') { + setCustomStartDate(day.dateString); + if (day.dateString > customEndDate) { + setCustomEndDate(day.dateString); + } + } else { + setCustomEndDate(day.dateString); + if (day.dateString < customStartDate) { + setCustomStartDate(day.dateString); + } + } + setActiveDatePicker(null); + }} + theme={{ + selectedDayBackgroundColor: theme.colors.primary, + todayTextColor: theme.colors.primary, + arrowColor: theme.colors.primary, + }} + /> + + )} + + )} + + + + + 조회: {periodTitle} ({filteredRecordsForPrint.length}회차 기록) + + + + )} + + + + + + + + + ); +} + +const createStyles = (theme) => StyleSheet.create({ + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.45)', + justifyContent: 'flex-end', + }, + modalContent: { + backgroundColor: theme.colors.white, + borderTopLeftRadius: 24, + borderTopRightRadius: 24, + padding: 24, + paddingBottom: 24, + maxHeight: '90%', + shadowColor: '#000', + shadowOffset: { width: 0, height: -4 }, + shadowOpacity: 0.1, + shadowRadius: 12, + elevation: 10, + }, + modalScrollContent: { + paddingBottom: 10, + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 16, + paddingBottom: 12, + borderBottomWidth: 1, + borderBottomColor: theme.colors.surfaceVariant, + }, + modalTitleContainer: { + flex: 1, + }, + modalTitle: { + fontSize: 18, + fontWeight: 'bold', + color: theme.colors.textPrimary, + marginBottom: 4, + }, + modalSubtitle: { + fontSize: 13, + color: theme.colors.textSecondary, + fontWeight: '500', + }, + modalCloseBtn: { + padding: 4, + }, + filterSectionLabel: { + fontSize: 13, + fontWeight: '700', + color: theme.colors.textPrimary, + marginBottom: 10, + }, + filterChipGroup: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 8, + marginBottom: 14, + }, + filterChip: { + paddingHorizontal: 12, + paddingVertical: 7, + borderRadius: 20, + backgroundColor: theme.colors.surfaceVariant, + borderWidth: 1, + borderColor: 'transparent', + }, + filterChipActive: { + backgroundColor: theme.colors.secondaryContainer, + borderColor: theme.colors.primary, + }, + filterChipText: { + fontSize: 13, + color: theme.colors.textSecondary, + fontWeight: '600', + }, + filterChipTextActive: { + color: theme.colors.primary, + fontWeight: '700', + }, + customDateContainer: { + backgroundColor: theme.colors.surfaceVariant + '60', + padding: 12, + borderRadius: 12, + marginBottom: 14, + borderWidth: 1, + borderColor: theme.colors.outline + '40', + }, + customDateRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }, + customDateBtn: { + flex: 1, + backgroundColor: theme.colors.white, + paddingVertical: 8, + paddingHorizontal: 10, + borderRadius: 8, + borderWidth: 1, + borderColor: theme.colors.outline, + alignItems: 'center', + }, + customDateBtnActive: { + borderColor: theme.colors.primary, + backgroundColor: theme.colors.secondaryContainer, + }, + customDateLabel: { + fontSize: 11, + color: theme.colors.textSecondary, + marginBottom: 2, + fontWeight: '600', + }, + customDateText: { + fontSize: 13, + fontWeight: '700', + color: theme.colors.textPrimary, + }, + customDateTilde: { + fontSize: 16, + fontWeight: 'bold', + color: theme.colors.textSecondary, + marginHorizontal: 8, + }, + inlineCustomCalendar: { + marginTop: 10, + backgroundColor: theme.colors.white, + borderRadius: 10, + padding: 8, + borderWidth: 1, + borderColor: theme.colors.outline + '40', + }, + inlineCustomCalendarHeader: { + paddingBottom: 6, + borderBottomWidth: 1, + borderBottomColor: theme.colors.surfaceVariant, + marginBottom: 6, + alignItems: 'center', + }, + inlineCustomCalendarTitle: { + fontSize: 12, + fontWeight: '700', + color: theme.colors.primary, + }, + summaryBadgeBox: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: theme.colors.secondaryContainer, + paddingHorizontal: 12, + paddingVertical: 8, + borderRadius: 8, + marginBottom: 16, + }, + summaryBadgeIcon: { + marginRight: 6, + }, + summaryBadgeText: { + fontSize: 12.5, + color: theme.colors.primary, + fontWeight: '600', + }, +}); + diff --git a/src/components/modals/ScheduleEditModal.js b/src/components/modals/ScheduleEditModal.js new file mode 100644 index 0000000..db7cd72 --- /dev/null +++ b/src/components/modals/ScheduleEditModal.js @@ -0,0 +1,802 @@ +import React, { useState, useEffect, useRef, useMemo } from 'react'; +import { + Modal, + View, + Text, + TextInput, + TouchableOpacity, + ScrollView, + KeyboardAvoidingView, + Keyboard, + Platform, + StyleSheet, + useWindowDimensions, +} from 'react-native'; +import { Feather } from '@expo/vector-icons'; +import { useTheme } from '../../context/ThemeContext'; +import { theme as defaultTheme } from '../../theme'; +import TimePickerModal from './TimePickerModal'; + +const DEFAULT_DAY_LABELS = ['월', '화', '수', '목', '금', '토', '일']; + +/** + * 주간 수업 일정 추가·편집, 학생 검색 선택, 시작 시간 선택 통합 모달 컴포넌트 + * @param {object} props - 모달 속성 + * @returns {React.JSX.Element} 주간 수업 일정 편집 모달 요소 + */ +export default function ScheduleEditModal({ + visible, + onClose, + selectedItem = null, + dayLabels = DEFAULT_DAY_LABELS, + students = [], + formStudentName = '', + formDayOfWeek = 1, + formStartTime = '10:00', + formSubject = '', + formAddress = '', + formPhoneInfo = '', + formStatusNote = '', + onChangeSubject, + onChangeAddress, + onChangePhoneInfo, + onChangeStatusNote, + onChangeStartTime, + onSelectDay, + onSelectStudent, + onSave, + onDelete, + matchedRecord = null, + onGoToClassRecord, +}) { + const { theme: activeTheme } = useTheme(); + const theme = activeTheme || defaultTheme; + const styles = useMemo(() => createStyles(theme), [theme]); + + const { height: screenHeight } = useWindowDimensions(); + const [studentPickerVisible, setStudentPickerVisible] = useState(false); + const [studentSearchQuery, setStudentSearchQuery] = useState(''); + const [timePickerVisible, setTimePickerVisible] = useState(false); + const [isKeyboardVisible, setIsKeyboardVisible] = useState(false); + const [keyboardHeight, setKeyboardHeight] = useState(0); + + const editModalScrollRef = useRef(null); + const focusedModalFieldRef = useRef(null); + + useEffect(() => { + if (!visible) { + setStudentPickerVisible(false); + setStudentSearchQuery(''); + setTimePickerVisible(false); + } + }, [visible]); + + useEffect(() => { + const showSub = Keyboard.addListener( + Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow', + (e) => { + setIsKeyboardVisible(true); + if (e?.endCoordinates?.height) { + setKeyboardHeight(e.endCoordinates.height); + } + if (focusedModalFieldRef.current === 'bottom') { + setTimeout(() => { + editModalScrollRef.current?.scrollToEnd({ animated: true }); + }, 80); + } + } + ); + const hideSub = Keyboard.addListener( + Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide', + () => { + setIsKeyboardVisible(false); + setKeyboardHeight(0); + focusedModalFieldRef.current = null; + } + ); + return () => { + showSub.remove(); + hideSub.remove(); + }; + }, []); + + const editModalScrollContentDynamicStyle = useMemo(() => [ + styles.modalScrollContent, + isKeyboardVisible && { + paddingBottom: Platform.OS === 'android' ? Math.max(keyboardHeight * 0.6, 120) : 48, + }, + ], [styles.modalScrollContent, isKeyboardVisible, keyboardHeight]); + + // 필터링된 학생 목록 (이름, 학교/학년, 전화번호 검색 + 가나다 오름차순 정렬) + const filteredStudents = useMemo(() => { + const list = [...students].sort((a, b) => + (a.name || '').localeCompare(b.name || '', 'ko') + ); + const trimmed = studentSearchQuery.trim(); + if (!trimmed) return list; + const q = trimmed.toLowerCase(); + const numericQ = q.replace(/-/g, ''); + return list.filter((s) => { + const nameMatch = (s.name || '').toLowerCase().includes(q); + const gradeMatch = (s.school_grade || '').toLowerCase().includes(q); + const mobileDigits = (s.mobile_phone || '').replace(/-/g, '').toLowerCase(); + const phoneDigits = (s.phone_number || '').replace(/-/g, '').toLowerCase(); + const mobileMatch = + (s.mobile_phone || '').toLowerCase().includes(q) || + (numericQ && mobileDigits.includes(numericQ)); + const phoneMatch = + (s.phone_number || '').toLowerCase().includes(q) || + (numericQ && phoneDigits.includes(numericQ)); + return nameMatch || gradeMatch || mobileMatch || phoneMatch; + }); + }, [students, studentSearchQuery]); + + const studentPickerContainerStyle = useMemo(() => { + if (!isKeyboardVisible) { + return [styles.studentPickerOverlay, styles.studentPickerOverlayCentered]; + } + return [ + styles.studentPickerOverlay, + styles.studentPickerOverlayKeyboard, + ]; + }, [styles.studentPickerOverlay, styles.studentPickerOverlayCentered, styles.studentPickerOverlayKeyboard, isKeyboardVisible]); + + const studentPickerContentStyle = useMemo(() => { + if (!isKeyboardVisible) { + return [ + styles.pickerModalContent, + styles.pickerModalContentCentered, + { maxHeight: Math.min(screenHeight * 0.75, 520) }, + ]; + } + const safeTop = Platform.OS === 'android' ? 36 : 48; + const availableHeight = screenHeight - keyboardHeight - safeTop - 20; + return [ + styles.pickerModalContent, + styles.pickerModalContentKeyboard, + { maxHeight: Math.max(availableHeight, 240) }, + ]; + }, [styles.pickerModalContent, styles.pickerModalContentCentered, styles.pickerModalContentKeyboard, isKeyboardVisible, keyboardHeight, screenHeight]); + + return ( + <> + + + { + if (isKeyboardVisible) { + Keyboard.dismiss(); + } else { + onClose(); + } + }} + > + true} + > + + + {selectedItem ? '수업 일정 변경 / 메모' : '새 주간 수업 등록'} + + + + + + + + {/* 학생 선택 버튼 */} + + 학생 선택 * + { + setStudentSearchQuery(''); + setStudentPickerVisible(true); + }} + > + + {formStudentName || '학생 주소록에서 선택하기'} + + + + + + {/* 요일 선택 */} + + + 요일 + + {dayLabels.map((d, idx) => ( + onSelectDay && onSelectDay(idx + 1)} + > + + {d} + + + ))} + + + + + {/* 시작 시간 */} + + 시작 시간 * + setTimePickerVisible(true)} + > + + ⏰ {formStartTime || '10:00'} + + + + + + {/* 과목 */} + + 수업 과목 / 과정 + + + + {/* 주소 및 연락처 */} + + 방문 주소 + { + focusedModalFieldRef.current = 'bottom'; + setTimeout(() => { + editModalScrollRef.current?.scrollTo({ y: 160, animated: true }); + }, 120); + }} + placeholder="거주지 주소" + placeholderTextColor={theme.colors.outline} + multiline={true} + /> + + + + 연락처 정보 + { + focusedModalFieldRef.current = 'bottom'; + setTimeout(() => { + editModalScrollRef.current?.scrollToEnd({ animated: true }); + }, 120); + }} + placeholder="(모)010-0000-0000 (본)010-0000-0000" + placeholderTextColor={theme.colors.outline} + multiline={true} + /> + + + {/* 변동 메모 및 프리셋 */} + + 이번 주 특이사항 / 변동 메모 (PDF에 인쇄됨) + + {['이번주만', '휴일', '아픔', '(30분 수업)'].map((chip) => ( + onChangeStatusNote && onChangeStatusNote(chip)} + > + {chip} + + ))} + + { + focusedModalFieldRef.current = 'bottom'; + setTimeout(() => { + editModalScrollRef.current?.scrollToEnd({ animated: true }); + }, 120); + }} + placeholder="예: => 이번주만, => 보강" + placeholderTextColor={theme.colors.outline} + /> + + + + {/* 하단 액션 버튼 */} + + {selectedItem && ( + + + 이번주 제외 + + )} + + {selectedItem && ( + + + + {matchedRecord ? '일지 수정' : '일지 작성'} + + + )} + + + + 저장하기 + + + + + + + + {/* 학생 선택 팝업 모달 */} + { + if (isKeyboardVisible) { + Keyboard.dismiss(); + } else { + setStudentPickerVisible(false); + } + }} + statusBarTranslucent + > + + { + if (isKeyboardVisible) { + Keyboard.dismiss(); + } else { + setStudentPickerVisible(false); + } + }} + /> + + true}> + + + + 학생 선택 + + { + Keyboard.dismiss(); + setStudentPickerVisible(false); + }} + hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} + > + + + + + + + + {studentSearchQuery.length > 0 && ( + setStudentSearchQuery('')} + hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} + > + + + )} + + + + {filteredStudents.length === 0 ? ( + + + + {studentSearchQuery ? '일치하는 학생이 없습니다.' : '등록된 학생이 없습니다.'} + + + ) : ( + filteredStudents.map((s) => ( + { + Keyboard.dismiss(); + setStudentPickerVisible(false); + if (onSelectStudent) onSelectStudent(s); + }} + > + + {s.name} + + {s.school_grade || '학년 미지정'} | {s.study_method || '방문'} + + + + + )) + )} + + + + + + {/* 시작 시간 GUI 선택 팝업 */} + onChangeStartTime && onChangeStartTime(t)} + onClose={() => setTimePickerVisible(false)} + /> + + ); +} + +const createStyles = (theme) => StyleSheet.create({ + modalOverlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.45)', + justifyContent: 'flex-end', + }, + modalKeyboardAvoid: { + flex: 1, + }, + editModalContent: { + backgroundColor: theme.colors.white, + borderTopLeftRadius: 20, + borderTopRightRadius: 20, + paddingHorizontal: 20, + paddingTop: 20, + paddingBottom: Platform.OS === 'android' ? 56 : 36, + maxHeight: '88%', + }, + editModalContentKeyboard: { + paddingBottom: 16, + maxHeight: '94%', + }, + modalHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + marginBottom: 16, + paddingBottom: 10, + borderBottomWidth: 1, + borderBottomColor: theme.colors.surfaceVariant, + }, + modalTitle: { + fontSize: 17, + fontWeight: 'bold', + color: theme.colors.textPrimary, + }, + modalScroll: { + flexShrink: 1, + }, + modalScrollContent: { + paddingBottom: 10, + }, + formGroup: { + marginBottom: 12, + }, + formRow: { + flexDirection: 'row', + gap: 10, + marginBottom: 12, + }, + formCol: { + gap: 4, + }, + formLabel: { + fontSize: 12, + fontWeight: '700', + color: theme.colors.textSecondary, + marginBottom: 4, + }, + reqStar: { + color: theme.colors.error, + }, + studentPickerBtn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: theme.colors.surfaceVariant + '50', + borderWidth: 1, + borderColor: theme.colors.outline + '40', + borderRadius: 8, + paddingHorizontal: 12, + paddingVertical: 10, + }, + studentPickerText: { + fontSize: 14, + fontWeight: '700', + color: theme.colors.textPrimary, + }, + studentPickerPlaceholder: { + fontSize: 13, + color: theme.colors.outline, + }, + daySelectRow: { + flexDirection: 'row', + gap: 6, + }, + daySelectPill: { + width: 38, + height: 34, + borderRadius: 6, + backgroundColor: theme.colors.surfaceVariant + '70', + alignItems: 'center', + justifyContent: 'center', + }, + daySelectPillActive: { + backgroundColor: theme.colors.primary, + }, + daySelectPillText: { + fontSize: 12, + fontWeight: '700', + color: theme.colors.textSecondary, + }, + daySelectPillTextActive: { + color: theme.colors.onPrimary, + }, + timeSelectorBtn: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + backgroundColor: theme.colors.surfaceVariant + '50', + borderWidth: 1, + borderColor: theme.colors.outline + '40', + borderRadius: 8, + paddingHorizontal: 10, + paddingVertical: 9, + }, + timeSelectorBtnText: { + fontSize: 13.5, + fontWeight: '700', + color: theme.colors.textPrimary, + }, + formInput: { + backgroundColor: theme.colors.surfaceVariant + '40', + borderWidth: 1, + borderColor: theme.colors.outline + '40', + borderRadius: 8, + paddingHorizontal: 10, + paddingVertical: 8, + fontSize: 13, + color: theme.colors.textPrimary, + }, + multilineInput: { + height: 50, + }, + presetChipRow: { + flexDirection: 'row', + flexWrap: 'wrap', + gap: 6, + marginBottom: 6, + }, + presetChip: { + backgroundColor: '#FEE2E2', + paddingHorizontal: 8, + paddingVertical: 4, + borderRadius: 6, + }, + presetChipText: { + fontSize: 10.5, + fontWeight: '700', + color: '#B91C1C', + }, + modalBtnRow: { + flexDirection: 'row', + justifyContent: 'flex-end', + alignItems: 'center', + gap: 10, + marginTop: 10, + paddingTop: 12, + borderTopWidth: 1, + borderTopColor: theme.colors.surfaceVariant, + }, + modalDeleteBtn: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingHorizontal: 12, + paddingVertical: 10, + borderRadius: 8, + backgroundColor: '#FEE2E2', + }, + modalDeleteBtnText: { + fontSize: 12, + fontWeight: '700', + color: theme.colors.error, + }, + modalRecordBtn: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingHorizontal: 12, + paddingVertical: 10, + borderRadius: 8, + backgroundColor: theme.colors.surfaceVariant, + }, + modalRecordBtnText: { + fontSize: 12, + fontWeight: '700', + color: theme.colors.primary, + }, + modalRecordBtnTextDone: { + color: '#16A34A', + }, + modalSaveBtn: { + flexDirection: 'row', + alignItems: 'center', + gap: 4, + paddingHorizontal: 18, + paddingVertical: 10, + borderRadius: 8, + backgroundColor: theme.colors.primary, + }, + modalSaveBtnText: { + fontSize: 13, + fontWeight: '700', + color: theme.colors.onPrimary, + }, + studentPickerOverlay: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.55)', + paddingHorizontal: 18, + }, + studentPickerOverlayCentered: { + justifyContent: 'center', + alignItems: 'center', + }, + studentPickerOverlayKeyboard: { + justifyContent: 'flex-start', + paddingTop: Platform.OS === 'android' ? 36 : 48, + }, + pickerModalContent: { + width: '100%', + backgroundColor: theme.colors.white, + borderRadius: 16, + padding: 18, + shadowColor: '#000', + shadowOffset: { width: 0, height: 4 }, + shadowOpacity: 0.25, + shadowRadius: 10, + elevation: 8, + }, + pickerModalContentCentered: { + borderRadius: 16, + }, + pickerModalContentKeyboard: { + borderRadius: 16, + }, + studentPickerTitleRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 8, + }, + searchBar: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: theme.colors.surfaceVariant + '60', + borderRadius: 8, + paddingHorizontal: 10, + marginBottom: 10, + }, + searchInput: { + flex: 1, + paddingVertical: 9, + paddingHorizontal: 8, + fontSize: 14, + color: theme.colors.textPrimary, + }, + studentListScroll: { + flexShrink: 1, + }, + studentListScrollContent: { + paddingBottom: 8, + }, + studentPickerEmpty: { + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 32, + gap: 8, + }, + studentPickerEmptyText: { + fontSize: 13, + color: theme.colors.textSecondary, + }, + studentItemRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: theme.colors.surfaceVariant, + }, + studentItemInfo: { + flex: 1, + }, + studentItemName: { + fontSize: 14, + fontWeight: '700', + color: theme.colors.textPrimary, + }, + studentItemSub: { + fontSize: 11, + color: theme.colors.textSecondary, + marginTop: 2, + }, +}); + diff --git a/src/screens/CalendarScreen.js b/src/screens/CalendarScreen.js index 0c67261..00a2efb 100644 --- a/src/screens/CalendarScreen.js +++ b/src/screens/CalendarScreen.js @@ -22,7 +22,6 @@ import { useNavigation, useIsFocused } from '@react-navigation/native'; import { Database } from '../database/Database'; import { useTheme } from '../context/ThemeContext'; import { getTodayFormatted } from '../utils/dateUtils'; -import { theme } from '../theme'; import Separator from '../components/Separator'; import FloatingActionButton from '../components/FloatingActionButton'; @@ -37,6 +36,8 @@ LocaleConfig.locales.ko = { LocaleConfig.defaultLocale = 'ko'; const CustomDay = React.memo(({ date, state, marking, onPress }) => { + const { theme } = useTheme(); + const styles = useMemo(() => createStyles(theme), [theme]); const isSelected = marking?.selected; const classCount = marking?.classCount || 0; const isToday = state === 'today'; @@ -73,8 +74,8 @@ 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(); + const styles = useMemo(() => createStyles(theme), [theme]); const navigation = useNavigation(); const isFocused = useIsFocused(); @@ -116,7 +117,7 @@ export default function CalendarScreen() { const modalOverlayDynamicStyle = useMemo(() => [ styles.modalOverlay, isKeyboardVisible && styles.modalOverlayKeyboard, - ], [isKeyboardVisible]); + ], [styles.modalOverlay, styles.modalOverlayKeyboard, isKeyboardVisible]); const modalContentDynamicStyle = useMemo(() => { if (!isKeyboardVisible) { @@ -129,7 +130,7 @@ export default function CalendarScreen() { styles.modalContentKeyboard, { height: undefined, maxHeight: Math.max(availableHeight, 240) }, ]; - }, [isKeyboardVisible, keyboardHeight, screenHeight]); + }, [styles.modalContent, styles.modalContentKeyboard, isKeyboardVisible, keyboardHeight, screenHeight]); // 달력 접기/펼치기 상태 및 애니메이션 관리를 위한 상태 및 ref const [isCollapsed, setIsCollapsed] = useState(false); @@ -153,7 +154,7 @@ export default function CalendarScreen() { ), }); - }, [navigation, theme.colors.primary]); + }, [navigation, theme.colors.primary, styles.headerRightContainer, styles.headerRightWeeklyBtn, styles.headerRightWeeklyBtnText]); // 달력 접기 애니메이션 처리 함수 const collapseCalendar = useCallback(() => { @@ -643,7 +644,7 @@ export default function CalendarScreen() { ); } -const styles = StyleSheet.create({ +const createStyles = (theme) => StyleSheet.create({ container: { flex: 1, backgroundColor: theme.colors.surfaceVariant, diff --git a/src/screens/ClassRecordScreen.js b/src/screens/ClassRecordScreen.js index 14bec39..5082c81 100644 --- a/src/screens/ClassRecordScreen.js +++ b/src/screens/ClassRecordScreen.js @@ -22,58 +22,17 @@ import { printClassRecords, shareClassRecords } from '../services/PrintService'; import { getTodayFormatted } from '../utils/dateUtils'; import { formatDisplayTime } from '../utils/stringUtils'; import { useTheme } from '../context/ThemeContext'; -import { theme } from '../theme'; -import ActionMenuItem from '../components/ActionMenuItem'; import FloatingActionButton from '../components/FloatingActionButton'; +import TimePickerModal from '../components/modals/TimePickerModal'; +import RecordPrintModal from '../components/modals/RecordPrintModal'; export { formatDisplayTime }; const PAGE_SIZE = 15; -// 수업 시간 선택 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', - '11:00', - '13:00', - '14:00', - '15:00', - '16:00', - '17:00', - '18:00', - '19:00', - '20:00', -]; - export default function ClassRecordScreen() { - // eslint-disable-next-line no-shadow const { theme } = useTheme(); + const styles = useMemo(() => createStyles(theme), [theme]); const navigation = useNavigation(); const route = useRoute(); const { studentId, recordId, selectedDate, initialDate, initialTime, initialCourse } = route.params || {}; @@ -85,20 +44,8 @@ export default function ClassRecordScreen() { const [displayedRecords, setDisplayedRecords] = useState([]); const [page, setPage] = useState(1); - // 출력 모달 및 기간 필터 상태 ('all': 전체, '1m': 최근 1개월, '3m': 최근 3개월, 'current_month': 이번 달, 'custom': 직접 설정) + // 출력 모달 상태 const [printModalVisible, setPrintModalVisible] = useState(false); - const [periodFilter, setPeriodFilter] = useState('all'); - - const [customStartDate, setCustomStartDate] = useState(() => { - const d = new Date(); - d.setMonth(d.getMonth() - 1); - const year = d.getFullYear(); - const month = String(d.getMonth() + 1).padStart(2, '0'); - const day = String(d.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; - }); - const [customEndDate, setCustomEndDate] = useState(getTodayFormatted()); - const [activeDatePicker, setActiveDatePicker] = useState(null); // 'start' | 'end' | null // 상단 헤더 우측 인쇄/공유 버튼 useLayoutEffect(() => { @@ -116,67 +63,7 @@ export default function ClassRecordScreen() { ), }); - }, [navigation, theme.colors.primary]); - - // 필터링된 일지 목록 및 기간명 계산 - const { filteredRecordsForPrint, periodTitle } = useMemo(() => { - if (!allRecords.length) { - return { filteredRecordsForPrint: [], periodTitle: '전체 기간' }; - } - - const today = new Date(); - const todayStr = today.toISOString().split('T')[0]; - - if (periodFilter === 'custom') { - const start = customStartDate <= customEndDate ? customStartDate : customEndDate; - const end = customStartDate <= customEndDate ? customEndDate : customStartDate; - const filtered = allRecords.filter(r => r.class_date >= start && r.class_date <= end); - return { - filteredRecordsForPrint: filtered, - periodTitle: `지정 기간 (${start} ~ ${end})`, - }; - } - - if (periodFilter === '1m') { - const oneMonthAgo = new Date(); - oneMonthAgo.setMonth(today.getMonth() - 1); - const limitStr = oneMonthAgo.toISOString().split('T')[0]; - const filtered = allRecords.filter(r => r.class_date >= limitStr); - return { - filteredRecordsForPrint: filtered, - periodTitle: `최근 1개월 (${limitStr} ~ ${todayStr})`, - }; - } - - if (periodFilter === '3m') { - const threeMonthsAgo = new Date(); - threeMonthsAgo.setMonth(today.getMonth() - 3); - const limitStr = threeMonthsAgo.toISOString().split('T')[0]; - const filtered = allRecords.filter(r => r.class_date >= limitStr); - return { - filteredRecordsForPrint: filtered, - periodTitle: `최근 3개월 (${limitStr} ~ ${todayStr})`, - }; - } - - if (periodFilter === 'current_month') { - const yearMonth = todayStr.substring(0, 7); // YYYY-MM - const filtered = allRecords.filter(r => r.class_date && r.class_date.startsWith(yearMonth)); - return { - filteredRecordsForPrint: filtered, - periodTitle: `${today.getFullYear()}년 ${today.getMonth() + 1}월`, - }; - } - - // 기본: 전체 기간 - const sorted = [...allRecords].sort((a, b) => a.class_date.localeCompare(b.class_date)); - const start = sorted[0]?.class_date || todayStr; - const end = sorted[sorted.length - 1]?.class_date || todayStr; - return { - filteredRecordsForPrint: allRecords, - periodTitle: `전체 기간 (${start} ~ ${end})`, - }; - }, [allRecords, periodFilter, customStartDate, customEndDate]); + }, [navigation, theme.colors.primary, styles.headerRightBtn]); // 개별 기록 작성/수정 모달 관련 상태 const modalScrollRef = useRef(null); @@ -265,50 +152,7 @@ export default function ClassRecordScreen() { isKeyboardVisible && { paddingBottom: Platform.OS === 'android' ? keyboardHeight + 36 : 36, }, - ], [isKeyboardVisible, keyboardHeight]); - - // 시간 파싱 헬퍼 (HH:mm 포맷) - const parsedTime = useMemo(() => { - if (!editingTime) { - return { ampm: '오전', hour24: 10, minute: '00', isEmpty: true }; - } - const match = editingTime.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: 14, minute: '00', isEmpty: false }; - }, [editingTime]); - - // 오전/오후 변경 핸들러 - const handleTimeAmpmChange = (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'; - setEditingTime(`${String(targetHour).padStart(2, '0')}:${m}`); - }; - - // 시(Hour) 선택 핸들러 - const handleTimeHourChange = (h24) => { - const m = parsedTime.minute || '00'; - setEditingTime(`${String(h24).padStart(2, '0')}:${m}`); - }; - - // 분(Minute) 선택 핸들러 - const handleTimeMinuteChange = (mStr) => { - const h24 = parsedTime.hour24; - setEditingTime(`${String(h24).padStart(2, '0')}:${mStr}`); - }; + ], [isKeyboardVisible, keyboardHeight, styles.modalScroll, styles.modalScrollWithKeyboard]); // 기록 추가 모달 열기 const openAddModal = useCallback((dateStr = '', timeStr = '', courseStr = '') => { @@ -493,7 +337,7 @@ export default function ClassRecordScreen() { ); - }, [openEditModal, handleDeleteRecord, theme.colors.primary, theme.colors.error]); + }, [openEditModal, handleDeleteRecord, theme.colors.primary, theme.colors.error, styles]); return ( @@ -662,8 +506,8 @@ export default function ClassRecordScreen() { style={[styles.dateSelector, showTimePicker && styles.dateSelectorActive]} onPress={() => { Keyboard.dismiss(); - setShowTimePicker(!showTimePicker); - if (!showTimePicker) setShowDatePicker(false); + setShowDatePicker(false); + setShowTimePicker(true); }} > @@ -671,151 +515,12 @@ export default function ClassRecordScreen() { ⏰ {editingTime || '수업 시간 선택'} - - {showTimePicker && ( - - {/* 1. 빠른 프리셋 */} - ⚡ 빠른 선택 - - {QUICK_TIME_PRESETS.map((preset) => { - const isSelected = editingTime === preset; - return ( - { - setEditingTime(preset); - }} - > - - {preset} - - - ); - })} - - - {/* 2. 오전/오후 탭 */} - - handleTimeAmpmChange('오전')} - > - - 오전 (AM) - - - handleTimeAmpmChange('오후')} - > - - 오후 (PM) - - - - - {/* 3. 시간(시) 선택 그리드 */} - - {parsedTime.ampm === '오전' ? '오전 시간 (시)' : '오후 시간 (시)'} - - - {(parsedTime.ampm === '오전' ? MORNING_HOURS : AFTERNOON_HOURS).map((h) => { - const isSelected = parsedTime.hour24 === h.hour24 && !parsedTime.isEmpty; - return ( - handleTimeHourChange(h.hour24)} - > - - {h.label} - - - ); - })} - - - {/* 4. 분(Minute) 선택 그리드 */} - 분 (Min) - - {MINUTE_OPTIONS.map((minStr) => { - const isSelected = parsedTime.minute === minStr && !parsedTime.isEmpty; - return ( - handleTimeMinuteChange(minStr)} - > - - {minStr}분 - - - ); - })} - - - {/* 선택 완료 버튼 */} - setShowTimePicker(false)} - > - - {editingTime ? `${editingTime} 선택 완료` : '선택 완료'} - - - - )} @@ -874,211 +579,31 @@ export default function ClassRecordScreen() { - {/* 수업 일지 출력 모달 (기간 선택 + 프린터 인쇄 vs PDF 공유) */} - setShowTimePicker(false)} + /> + + {/* 공통 수업 일지 출력 모달 */} + setPrintModalVisible(false)} - statusBarTranslucent - > - setPrintModalVisible(false)} - > - e.stopPropagation()} - > - - - - 수업 일지 보고서 출력 - - {student?.name || '학생'} 학생의 수업 기록 ({filteredRecordsForPrint.length}건 선택됨) - - - setPrintModalVisible(false)} style={styles.actionModalCloseBtn}> - - - - - {/* 기간 필터 선택 칩 */} - 출력 기간 선택 - - { - setPeriodFilter('all'); - setActiveDatePicker(null); - }} - > - - 전체 기간 - - - - { - setPeriodFilter('current_month'); - setActiveDatePicker(null); - }} - > - - 이번 달 - - - - { - setPeriodFilter('1m'); - setActiveDatePicker(null); - }} - > - - 최근 1개월 - - - - { - setPeriodFilter('3m'); - setActiveDatePicker(null); - }} - > - - 최근 3개월 - - - - { - setPeriodFilter('custom'); - setActiveDatePicker(null); - }} - > - - 직접 설정 - - - - - {/* 직접 설정 선택 시 시작일 ~ 종료일 선택기 */} - {periodFilter === 'custom' && ( - - - setActiveDatePicker(activeDatePicker === 'start' ? null : 'start')} - > - 시작일 - {customStartDate} - - - ~ - - setActiveDatePicker(activeDatePicker === 'end' ? null : 'end')} - > - 종료일 - {customEndDate} - - - - {/* 날짜 선택 인라인 캘린더 */} - {activeDatePicker && ( - - - - {activeDatePicker === 'start' ? '시작 날짜 선택' : '종료 날짜 선택'} - - - { - if (activeDatePicker === 'start') { - setCustomStartDate(day.dateString); - if (day.dateString > customEndDate) { - setCustomEndDate(day.dateString); - } - } else { - setCustomEndDate(day.dateString); - if (day.dateString < customStartDate) { - setCustomStartDate(day.dateString); - } - } - setActiveDatePicker(null); - }} - theme={{ - selectedDayBackgroundColor: theme.colors.primary, - todayTextColor: theme.colors.primary, - arrowColor: theme.colors.primary, - }} - /> - - )} - - )} - - {/* 선택된 대상 요약 배너 */} - - - - 조회: {periodTitle} ({filteredRecordsForPrint.length}회차 기록) - - - - {/* 메뉴 1: 무선/유선 프린터로 인쇄 */} - { - setPrintModalVisible(false); - await printClassRecords(student, filteredRecordsForPrint, periodTitle); - }} - /> - - {/* 메뉴 2: PDF 파일 공유 (카톡/메시지) */} - { - setPrintModalVisible(false); - await shareClassRecords(student, filteredRecordsForPrint, periodTitle); - }} - /> - - - - + onClose={() => setPrintModalVisible(false)} + printType="records" + student={student} + records={allRecords} + onPrintRecords={(stud, recs, title) => printClassRecords(stud, recs, title)} + onShareRecords={(stud, recs, title) => shareClassRecords(stud, recs, title)} + /> ); } -const styles = StyleSheet.create({ +const createStyles = (theme) => StyleSheet.create({ container: { flex: 1, backgroundColor: theme.colors.surfaceVariant, @@ -1338,118 +863,6 @@ const styles = StyleSheet.create({ borderRadius: theme.roundness, overflow: 'hidden', }, - inlineTimePicker: { - marginTop: 8, - borderWidth: 1, - borderColor: theme.colors.secondaryContainer, - borderRadius: theme.roundness, - backgroundColor: theme.colors.white, - padding: 12, - }, - timePickerSectionTitle: { - fontSize: 12, - fontWeight: '700', - color: theme.colors.textSecondary, - marginBottom: 6, - marginTop: 6, - }, - quickPresetScroll: { - paddingVertical: 2, - marginBottom: 8, - }, - 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.white, - 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: 7, - 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: { - marginTop: 6, - backgroundColor: theme.colors.primary, - paddingVertical: 9, - borderRadius: 8, - alignItems: 'center', - }, - timeConfirmBtnText: { - color: theme.colors.white, - fontSize: 13, - fontWeight: 'bold', - }, modalInput: { borderWidth: 1, borderColor: theme.colors.outline, @@ -1489,160 +902,6 @@ const styles = StyleSheet.create({ fontWeight: 'bold', fontSize: 15, }, - actionModalOverlay: { - flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.45)', - justifyContent: 'flex-end', - }, - actionModalContent: { - backgroundColor: theme.colors.white, - borderTopLeftRadius: 24, - borderTopRightRadius: 24, - padding: 24, - paddingBottom: 24, - maxHeight: '90%', - shadowColor: '#000', - shadowOffset: { width: 0, height: -4 }, - shadowOpacity: 0.1, - shadowRadius: 12, - elevation: 10, - }, - actionModalHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 16, - paddingBottom: 12, - borderBottomWidth: 1, - borderBottomColor: theme.colors.surfaceVariant, - }, - actionModalTitleContainer: { - flex: 1, - }, - actionModalTitle: { - fontSize: 18, - fontWeight: 'bold', - color: theme.colors.textPrimary, - marginBottom: 4, - }, - actionModalSubtitle: { - fontSize: 13, - color: theme.colors.textSecondary, - }, - actionModalCloseBtn: { - padding: 4, - }, - filterSectionLabel: { - fontSize: 13, - fontWeight: '700', - color: theme.colors.textPrimary, - marginBottom: 10, - }, - filterChipGroup: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 8, - marginBottom: 14, - }, - filterChip: { - paddingHorizontal: 12, - paddingVertical: 7, - borderRadius: 20, - backgroundColor: theme.colors.surfaceVariant, - borderWidth: 1, - borderColor: 'transparent', - }, - filterChipActive: { - backgroundColor: '#EFF6FF', - borderColor: theme.colors.primary, - }, - filterChipText: { - fontSize: 13, - color: theme.colors.textSecondary, - fontWeight: '600', - }, - filterChipTextActive: { - color: theme.colors.primary, - fontWeight: '700', - }, - summaryBadgeBox: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: theme.colors.secondaryContainer, - paddingHorizontal: 12, - paddingVertical: 8, - borderRadius: 8, - marginBottom: 16, - }, - summaryBadgeText: { - fontSize: 12.5, - color: theme.colors.primary, - fontWeight: '600', - }, - customDateContainer: { - backgroundColor: theme.colors.surfaceVariant + '60', - padding: 12, - borderRadius: 12, - marginBottom: 14, - borderWidth: 1, - borderColor: theme.colors.outline + '40', - }, - customDateRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - customDateBtn: { - flex: 1, - backgroundColor: theme.colors.white, - paddingVertical: 8, - paddingHorizontal: 10, - borderRadius: 8, - borderWidth: 1, - borderColor: theme.colors.outline, - alignItems: 'center', - }, - customDateBtnActive: { - borderColor: theme.colors.primary, - backgroundColor: '#EFF6FF', - }, - customDateLabel: { - fontSize: 11, - color: theme.colors.textSecondary, - marginBottom: 2, - fontWeight: '600', - }, - customDateText: { - fontSize: 13, - fontWeight: '700', - color: theme.colors.textPrimary, - }, - customDateTilde: { - fontSize: 16, - fontWeight: 'bold', - color: theme.colors.textSecondary, - marginHorizontal: 8, - }, - inlineCustomCalendar: { - marginTop: 10, - backgroundColor: theme.colors.white, - borderRadius: 10, - padding: 8, - borderWidth: 1, - borderColor: theme.colors.outline + '40', - }, - inlineCustomCalendarHeader: { - paddingBottom: 6, - borderBottomWidth: 1, - borderBottomColor: theme.colors.surfaceVariant, - marginBottom: 6, - alignItems: 'center', - }, - inlineCustomCalendarTitle: { - fontSize: 12, - fontWeight: '700', - color: theme.colors.primary, - }, studentBarLeft: { flex: 1, }, @@ -1652,10 +911,4 @@ const styles = StyleSheet.create({ emptyFolderIcon: { marginBottom: 16, }, - modalScrollContent: { - paddingBottom: 10, - }, - summaryBadgeIcon: { - marginRight: 6, - }, }); diff --git a/src/screens/HelpScreen.js b/src/screens/HelpScreen.js index 36ef4c4..5131fcf 100644 --- a/src/screens/HelpScreen.js +++ b/src/screens/HelpScreen.js @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { View, Text, @@ -9,12 +9,13 @@ import { } from 'react-native'; import { Feather, MaterialCommunityIcons } from '@expo/vector-icons'; import { useTheme } from '../context/ThemeContext'; -import { theme } from '../theme'; + import packageJson from '../../package.json'; export default function HelpScreen() { - // eslint-disable-next-line no-shadow + const { theme } = useTheme(); + const styles = useMemo(() => createStyles(theme), [theme]); const [activeTab, setActiveTab] = useState('guide'); // 'guide' | 'about' | 'faq' const appGuides = [ @@ -307,7 +308,7 @@ export default function HelpScreen() { ); } -const styles = StyleSheet.create({ +const createStyles = (theme) => StyleSheet.create({ container: { flex: 1, backgroundColor: theme.colors.surfaceVariant, diff --git a/src/screens/HomeScreen.js b/src/screens/HomeScreen.js index ee6d64c..ebbc317 100644 --- a/src/screens/HomeScreen.js +++ b/src/screens/HomeScreen.js @@ -12,11 +12,10 @@ import { useNavigation, useIsFocused } from '@react-navigation/native'; import { Database } from '../database/Database'; import { useTheme } from '../context/ThemeContext'; import { DAY_NAMES, getTodayFormatted } from '../utils/dateUtils'; -import { theme } from '../theme'; export default function HomeScreen() { - // eslint-disable-next-line no-shadow const { theme } = useTheme(); + const styles = useMemo(() => createStyles(theme), [theme]); const navigation = useNavigation(); const isFocused = useIsFocused(); @@ -438,10 +437,10 @@ export default function HomeScreen() { ); } -const styles = StyleSheet.create({ +const createStyles = (theme) => StyleSheet.create({ container: { flex: 1, - backgroundColor: '#F8FAFC', + backgroundColor: theme.colors.surfaceVariant, }, scrollContent: { paddingHorizontal: 16, @@ -517,7 +516,7 @@ const styles = StyleSheet.create({ marginBottom: 2, }, highlightText: { - color: '#2563EB', + color: theme.colors.primary, fontWeight: '800', }, highlightGreen: { @@ -598,13 +597,13 @@ const styles = StyleSheet.create({ }, progressBar: { height: '100%', - backgroundColor: '#4F46E5', + backgroundColor: theme.colors.primary, borderRadius: 2, }, metricBottomHint: { fontSize: 9.5, fontWeight: '700', - color: '#4F46E5', + color: theme.colors.primary, marginTop: 2, }, sectionContainer: { @@ -633,7 +632,7 @@ const styles = StyleSheet.create({ }, seeAllText: { fontSize: 12, - color: '#4F46E5', + color: theme.colors.primary, fontWeight: '700', }, loader: { @@ -675,7 +674,7 @@ const styles = StyleSheet.create({ flexDirection: 'row', alignItems: 'center', gap: 6, - backgroundColor: '#EEF2FF', + backgroundColor: theme.colors.secondaryContainer, paddingHorizontal: 14, paddingVertical: 8, borderRadius: 8, @@ -683,7 +682,7 @@ const styles = StyleSheet.create({ emptyAddBtnText: { fontSize: 12, fontWeight: '700', - color: '#4F46E5', + color: theme.colors.primary, }, scheduleCard: { backgroundColor: '#FFFFFF', diff --git a/src/screens/SettingsScreen.js b/src/screens/SettingsScreen.js index 4f4c4d8..10a4c6a 100644 --- a/src/screens/SettingsScreen.js +++ b/src/screens/SettingsScreen.js @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { View, Text, @@ -12,12 +12,13 @@ import { import { Feather } from '@expo/vector-icons'; import { Database } from '../database/Database'; import { GoogleDriveService } from '../services/GoogleDriveService'; -import { theme } from '../theme'; +import { theme as defaultTheme } from '../theme'; import { useTheme } from '../context/ThemeContext'; export default function SettingsScreen({ navigation }) { const { theme: activeTheme, currentThemeId, setThemeId, presets } = useTheme(); - const currentTheme = activeTheme || theme; + const currentTheme = activeTheme || defaultTheme; + const styles = useMemo(() => createStyles(currentTheme), [currentTheme]); const [activeTab, setActiveTab] = useState('backup'); // 'backup' | 'theme' const [loading, setLoading] = useState(false); const [userInfo, setUserInfo] = useState(null); @@ -379,7 +380,7 @@ export default function SettingsScreen({ navigation }) { {loading && ( - + 처리 중입니다... )} @@ -387,7 +388,7 @@ export default function SettingsScreen({ navigation }) { ); } -const styles = StyleSheet.create({ +const createStyles = (theme) => StyleSheet.create({ segmentContainer: { flexDirection: 'row', backgroundColor: '#E8ECF0', diff --git a/src/screens/StudentDetailScreen.js b/src/screens/StudentDetailScreen.js index 47b5775..036e2cb 100644 --- a/src/screens/StudentDetailScreen.js +++ b/src/screens/StudentDetailScreen.js @@ -11,8 +11,6 @@ import { KeyboardAvoidingView, Keyboard, Platform, - ImageBackground, - Modal } from 'react-native'; import { useNavigation, useRoute } from '@react-navigation/native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; @@ -22,22 +20,26 @@ import { printStudentProfile, shareStudentProfile } from '../services/PrintServi import { Feather } from '@expo/vector-icons'; 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'; +import RecordPrintModal from '../components/modals/RecordPrintModal'; +import ConfirmModal from '../components/ConfirmModal'; /** * 학생 개인 프로필, 수강 상태(재원/휴회), 정규 수업 일정 및 특이사항을 등록·수정하는 화면 컴포넌트 * @returns {React.JSX.Element} 학생 상세 정보 화면 요소 */ export default function StudentDetailScreen() { - // eslint-disable-next-line no-shadow const { theme } = useTheme(); + const styles = useMemo(() => createStyles(theme), [theme]); const insets = useSafeAreaInsets(); const navigation = useNavigation(); const route = useRoute(); const { studentId } = route.params || {}; + const initialSnapshotRef = useRef(null); + const isSavedRef = useRef(false); + const [leaveConfirmVisible, setLeaveConfirmVisible] = useState(false); + const [pendingRemoveAction, setPendingRemoveAction] = useState(null); const isEditMode = !!studentId; @@ -114,7 +116,52 @@ export default function StudentDetailScreen() { isKeyboardVisible && { paddingBottom: Platform.OS === 'android' ? keyboardHeight + 48 : 80, }, - ], [isKeyboardVisible, keyboardHeight]); + ], [styles.scrollContent, isKeyboardVisible, keyboardHeight]); + + const currentSnapshotStr = useMemo(() => JSON.stringify({ + name: name.trim(), + school_grade: schoolGrade.trim() || null, + resident_number: residentNumber.trim() || null, + address: address.trim() || null, + phone_number: phoneNumber.trim() || null, + email: email.trim() || null, + mobile_phone: mobilePhone.trim() || null, + study_method: studyMethod.trim() || null, + parent_name: parentName.trim() || null, + parent_mobile_phone: parentMobilePhone.trim() || null, + default_schedules: defaultSchedules, + notes: notes.trim() || null, + status, + first_enrolled_date: firstEnrolledDate, + current_term_number: currentTermNumber, + terms, + }), [ + name, schoolGrade, residentNumber, address, phoneNumber, email, mobilePhone, + studyMethod, parentName, parentMobilePhone, defaultSchedules, notes, + status, firstEnrolledDate, currentTermNumber, terms, + ]); + + useEffect(() => { + if (!isEditMode && initialSnapshotRef.current === null) { + initialSnapshotRef.current = currentSnapshotStr; + } + }, [isEditMode, currentSnapshotStr]); + + const hasUnsavedChanges = + initialSnapshotRef.current !== null && currentSnapshotStr !== initialSnapshotRef.current; + + useEffect(() => { + if (!navigation?.addListener) return undefined; + const unsubscribe = navigation.addListener('beforeRemove', (e) => { + if (isSavedRef.current || !hasUnsavedChanges) { + return; + } + e.preventDefault(); + setPendingRemoveAction(e.data.action); + setLeaveConfirmVisible(true); + }); + return unsubscribe; + }, [navigation, hasUnsavedChanges]); const activeScheduleTime = useMemo(() => { if (!activeScheduleIdForTime) return '10:00'; @@ -160,7 +207,7 @@ export default function StudentDetailScreen() { ), }); } - }, [navigation, isEditMode, theme.colors.primary]); + }, [navigation, isEditMode, theme.colors.primary, styles.headerRightBtn]); // 입력 자동 포맷팅 핸들러 const formatPhoneNumber = (text) => { @@ -210,15 +257,38 @@ export default function StudentDetailScreen() { // 수강 상태 및 차수(terms) 로드 const stTerms = Array.isArray(student.terms) && student.terms.length > 0 ? student.terms : []; - setTerms(stTerms); - setStatus(student.status || 'active'); - setFirstEnrolledDate( + const loadedStatus = student.status || 'active'; + const loadedFirstEnrolled = student.first_enrolled_date || - student.start_date || - stTerms[0]?.start_date || - (student.created_at ? student.created_at.split('T')[0] : getTodayFormatted()) - ); - setCurrentTermNumber(student.current_term_number || stTerms.length || 1); + student.start_date || + stTerms[0]?.start_date || + (student.created_at ? student.created_at.split('T')[0] : getTodayFormatted()); + const loadedTermNumber = student.current_term_number || stTerms.length || 1; + const loadedSchedules = Array.isArray(student.default_schedules) ? student.default_schedules : []; + + setTerms(stTerms); + setStatus(loadedStatus); + setFirstEnrolledDate(loadedFirstEnrolled); + setCurrentTermNumber(loadedTermNumber); + + initialSnapshotRef.current = JSON.stringify({ + name: (student.name || '').trim(), + school_grade: (student.school_grade || '').trim() || null, + resident_number: (student.resident_number || '').trim() || null, + address: (student.address || '').trim() || null, + phone_number: (student.phone_number || '').trim() || null, + email: (student.email || '').trim() || null, + mobile_phone: (student.mobile_phone || '').trim() || null, + study_method: (student.study_method || '').trim() || null, + parent_name: (student.parent_name || '').trim() || null, + parent_mobile_phone: (student.parent_mobile_phone || '').trim() || null, + default_schedules: loadedSchedules, + notes: (student.notes || '').trim() || null, + status: loadedStatus, + first_enrolled_date: loadedFirstEnrolled, + current_term_number: loadedTermNumber, + terms: stTerms, + }); } } catch (e) { console.error('Failed to load student details:', e); @@ -277,6 +347,15 @@ export default function StudentDetailScreen() { setStatus('paused'); setTerms(updated.terms); setCurrentTermNumber(updated.current_term_number); + if (initialSnapshotRef.current) { + const prev = JSON.parse(initialSnapshotRef.current); + initialSnapshotRef.current = JSON.stringify({ + ...prev, + status: 'paused', + terms: updated.terms, + current_term_number: updated.current_term_number, + }); + } setPauseModalVisible(false); Alert.alert('완료', `${name} 학생이 휴회 처리되었습니다.`); } catch (e) { @@ -296,6 +375,15 @@ export default function StudentDetailScreen() { setStatus('active'); setTerms(updated.terms); setCurrentTermNumber(updated.current_term_number); + if (initialSnapshotRef.current) { + const prev = JSON.parse(initialSnapshotRef.current); + initialSnapshotRef.current = JSON.stringify({ + ...prev, + status: 'active', + terms: updated.terms, + current_term_number: updated.current_term_number, + }); + } setResumeModalVisible(false); Alert.alert('완료', `${name} 학생의 재수강이 시작되었습니다.`); } catch (e) { @@ -344,11 +432,13 @@ export default function StudentDetailScreen() { try { if (isEditMode) { await Database.updateStudent(studentId, studentData); + isSavedRef.current = true; Alert.alert('완료', '학생 정보가 수정되었습니다.', [ { text: '확인', onPress: () => navigation.goBack() }, ]); } else { await Database.addStudent(studentData); + isSavedRef.current = true; Alert.alert('완료', '새로운 학생이 등록되었습니다.', [ { text: '확인', onPress: () => navigation.goBack() }, ]); @@ -372,6 +462,7 @@ export default function StudentDetailScreen() { onPress: async () => { try { await Database.deleteStudent(studentId); + isSavedRef.current = true; Alert.alert('완료', '학생 주소록이 삭제되었습니다.', [ { text: '확인', onPress: () => navigation.goBack() } ]); @@ -429,12 +520,14 @@ export default function StudentDetailScreen() { {status === 'paused' ? '현재 휴회 상태입니다' : `현재 ${currentTermNumber}차 수강 진행 중입니다`} - {status === 'paused' - ? '새 학기/새 차수로 복귀 시 [재수강 시작]을 누르세요' - : '수강을 잠시 쉴 경우 [휴회 처리]를 누르세요'} + {isEditMode + ? status === 'paused' + ? '새 학기/새 차수로 복귀 시 [재수강 시작]을 누르세요' + : '수강을 잠시 쉴 경우 [휴회 처리]를 누르세요' + : '신규 등록 시 초기 수강 상태(재원/휴회)를 전환할 수 있습니다'} - {isEditMode && ( + {isEditMode ? ( status === 'paused' ? ( 휴회 처리 ) + ) : ( + setStatus((prev) => (prev === 'paused' ? 'active' : 'paused'))} + accessibilityRole="button" + accessibilityLabel="초기 수강 상태 전환" + > + + + {status === 'paused' ? '재원으로 변경' : '휴회로 변경'} + + )} @@ -819,17 +932,15 @@ export default function StudentDetailScreen() { /> - {/* Image Banner */} - - - - 기록은 성장의 밑거름입니다. - - + {/* 오프라인 테마 배너 카드 */} + + + + + + 기록은 성장의 밑거름입니다. + 학생의 배움과 성장을 차곡차곡 기록해보세요. + @@ -860,56 +971,41 @@ export default function StudentDetailScreen() { )} {/* 학생 카드 출력 모달 (프린터 인쇄 vs PDF 공유) */} - setPrintModalVisible(false)} - > - setPrintModalVisible(false)} - > - - - - 학생 정보 카드 출력 - {name || '학생'} 학생의 인적사항 양식 - - setPrintModalVisible(false)} style={styles.modalCloseBtn}> - - - - - {/* 메뉴 1: 무선/유선 프린터로 인쇄 */} - { - setPrintModalVisible(false); - await printStudentProfile(getCurrentStudentData()); - }} - /> + onClose={() => setPrintModalVisible(false)} + printType="student" + student={getCurrentStudentData()} + onPrintStudent={async (targetStudent) => { + await printStudentProfile(targetStudent); + }} + onShareStudent={async (targetStudent) => { + await shareStudentProfile(targetStudent); + }} + /> - {/* 메뉴 2: PDF 파일 공유 (카톡/메시지) */} - { - setPrintModalVisible(false); - await shareStudentProfile(getCurrentStudentData()); - }} - /> - - - + {/* 미저장 변경사항 이탈 확인 모달 */} + { + setLeaveConfirmVisible(false); + isSavedRef.current = true; + if (pendingRemoveAction) { + navigation.dispatch(pendingRemoveAction); + } else { + navigation.goBack(); + } + }} + onCancel={() => { + setLeaveConfirmVisible(false); + setPendingRemoveAction(null); + }} + /> {/* 휴회 처리 입력 모달 */} StyleSheet.create({ container: { flex: 1, backgroundColor: theme.colors.surfaceVariant, @@ -1115,31 +1211,38 @@ const styles = StyleSheet.create({ fontSize: 14, color: theme.colors.textPrimary, }, - bannerContainer: { + bannerCard: { marginTop: 24, marginBottom: 16, - height: 128, + padding: 18, borderRadius: theme.roundness, - overflow: 'hidden', + backgroundColor: theme.colors.secondaryContainer, + borderWidth: 1, + borderColor: theme.colors.outlineVariant || theme.colors.outline, + flexDirection: 'row', + alignItems: 'center', + gap: 14, }, - bannerImage: { - width: '100%', - height: '100%', - justifyContent: 'flex-end', + bannerIconWrap: { + width: 44, + height: 44, + borderRadius: 12, + backgroundColor: theme.colors.surface, + alignItems: 'center', + justifyContent: 'center', }, - bannerOverlay: { - position: 'absolute', - bottom: 16, - left: 16, - zIndex: 20, + bannerTextWrap: { + flex: 1, }, - bannerText: { - color: theme.colors.white, - fontSize: 18, + bannerTitle: { + fontSize: 15, fontWeight: 'bold', - textShadowColor: 'rgba(0, 0, 0, 0.4)', - textShadowOffset: { width: 0, height: 1 }, - textShadowRadius: 4, + color: theme.colors.onSecondaryContainer || theme.colors.textPrimary, + marginBottom: 3, + }, + bannerSubtitle: { + fontSize: 12, + color: theme.colors.textSecondary, }, bottomFixedBar: { position: 'absolute', @@ -1191,60 +1294,12 @@ const styles = StyleSheet.create({ fontWeight: 'bold', fontSize: 14, }, - modalOverlay: { - flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.45)', - justifyContent: 'flex-end', - }, - modalContent: { - backgroundColor: theme.colors.white, - borderTopLeftRadius: 24, - borderTopRightRadius: 24, - padding: 24, - paddingBottom: 36, - shadowColor: '#000', - shadowOffset: { width: 0, height: -4 }, - shadowOpacity: 0.1, - shadowRadius: 12, - elevation: 10, - }, - modalHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 20, - paddingBottom: 14, - borderBottomWidth: 1, - borderBottomColor: theme.colors.surfaceVariant, - }, - modalTitleContainer: { - flex: 1, - }, - modalTitle: { - fontSize: 18, - fontWeight: 'bold', - color: theme.colors.textPrimary, - marginBottom: 4, - }, - modalSubtitle: { - fontSize: 13, - color: theme.colors.textSecondary, - }, - modalCloseBtn: { - padding: 4, - }, headerBtnIcon: { marginRight: 4, }, sectionHeaderMargin: { marginTop: 24, }, - bannerImageBorder: { - borderRadius: 20, - }, - pdfShareIconBadge: { - backgroundColor: '#E0F2FE', - }, addSchedBtn: { flexDirection: 'row', alignItems: 'center', diff --git a/src/screens/StudentListScreen.js b/src/screens/StudentListScreen.js index 85cf732..dfe9a71 100644 --- a/src/screens/StudentListScreen.js +++ b/src/screens/StudentListScreen.js @@ -9,11 +9,9 @@ import { SafeAreaView, ActivityIndicator, Modal, - Alert, - ScrollView + Alert } from 'react-native'; import { useNavigation, useIsFocused } from '@react-navigation/native'; -import { Calendar } from 'react-native-calendars'; import { Feather } from '@expo/vector-icons'; import { Database } from '../database/Database'; import { @@ -24,16 +22,16 @@ import { } from '../services/PrintService'; import { useTheme } from '../context/ThemeContext'; import { getTodayFormatted } from '../utils/dateUtils'; -import { theme } from '../theme'; import StudentStatusModal from '../components/modals/StudentStatusModal'; +import RecordPrintModal from '../components/modals/RecordPrintModal'; import Separator from '../components/Separator'; import ActionMenuItem from '../components/ActionMenuItem'; import FloatingActionButton from '../components/FloatingActionButton'; import StatusBadge from '../components/StatusBadge'; export default function StudentListScreen() { - // eslint-disable-next-line no-shadow const { theme } = useTheme(); + const styles = useMemo(() => createStyles(theme), [theme]); const navigation = useNavigation(); const isFocused = useIsFocused(); @@ -75,79 +73,8 @@ export default function StudentListScreen() { return { activeCount: active, pausedCount: paused, totalCount: students.length }; }, [students]); - // 수업일지 출력 기간 필터 관련 상태 + // 수업일지 출력 모달용 기록 목록 const [selectedStudentRecords, setSelectedStudentRecords] = useState([]); - const [periodFilter, setPeriodFilter] = useState('all'); - const [customStartDate, setCustomStartDate] = useState(() => { - const d = new Date(); - d.setMonth(d.getMonth() - 1); - const year = d.getFullYear(); - const month = String(d.getMonth() + 1).padStart(2, '0'); - const day = String(d.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; - }); - const [customEndDate, setCustomEndDate] = useState(getTodayFormatted()); - const [activeDatePicker, setActiveDatePicker] = useState(null); // 'start' | 'end' | null - - // 필터링된 일지 목록 및 기간명 계산 - const { filteredRecordsForPrint, periodTitle } = useMemo(() => { - if (!selectedStudentRecords.length) { - return { filteredRecordsForPrint: [], periodTitle: '전체 기간' }; - } - - const today = new Date(); - const todayStr = today.toISOString().split('T')[0]; - - if (periodFilter === 'custom') { - const start = customStartDate <= customEndDate ? customStartDate : customEndDate; - const end = customStartDate <= customEndDate ? customEndDate : customStartDate; - const filtered = selectedStudentRecords.filter(r => r.class_date >= start && r.class_date <= end); - return { - filteredRecordsForPrint: filtered, - periodTitle: `지정 기간 (${start} ~ ${end})`, - }; - } - - if (periodFilter === '1m') { - const oneMonthAgo = new Date(); - oneMonthAgo.setMonth(today.getMonth() - 1); - const limitStr = oneMonthAgo.toISOString().split('T')[0]; - const filtered = selectedStudentRecords.filter(r => r.class_date >= limitStr); - return { - filteredRecordsForPrint: filtered, - periodTitle: `최근 1개월 (${limitStr} ~ ${todayStr})`, - }; - } - - if (periodFilter === '3m') { - const threeMonthsAgo = new Date(); - threeMonthsAgo.setMonth(today.getMonth() - 3); - const limitStr = threeMonthsAgo.toISOString().split('T')[0]; - const filtered = selectedStudentRecords.filter(r => r.class_date >= limitStr); - return { - filteredRecordsForPrint: filtered, - periodTitle: `최근 3개월 (${limitStr} ~ ${todayStr})`, - }; - } - - if (periodFilter === 'current_month') { - const yearMonth = todayStr.substring(0, 7); // YYYY-MM - const filtered = selectedStudentRecords.filter(r => r.class_date && r.class_date.startsWith(yearMonth)); - return { - filteredRecordsForPrint: filtered, - periodTitle: `${today.getFullYear()}년 ${today.getMonth() + 1}월`, - }; - } - - // 기본: 전체 기간 - const sorted = [...selectedStudentRecords].sort((a, b) => a.class_date.localeCompare(b.class_date)); - const start = sorted[0]?.class_date || todayStr; - const end = sorted[sorted.length - 1]?.class_date || todayStr; - return { - filteredRecordsForPrint: selectedStudentRecords, - periodTitle: `전체 기간 (${start} ~ ${end})`, - }; - }, [selectedStudentRecords, periodFilter, customStartDate, customEndDate]); // 학생 클릭 처리: 선택 모달 노출 const handleStudentPress = (student) => { @@ -181,45 +108,11 @@ export default function StudentListScreen() { if (!selectedStudent) return; setActionModalVisible(false); setPrintType('records'); - setPeriodFilter('all'); - setActiveDatePicker(null); const recs = await Database.getRecordsByStudent(selectedStudent.id); setSelectedStudentRecords(recs || []); setPrintOptionModalVisible(true); }; - // 인쇄 실행 - const handleExecutePrint = async () => { - if (!selectedStudent) return; - setPrintOptionModalVisible(false); - - if (printType === 'student') { - await printStudentProfile(selectedStudent); - } else { - if (filteredRecordsForPrint.length === 0) { - Alert.alert('알림', `${selectedStudent.name} 학생의 해당 기간 수업 일지가 없습니다.`); - return; - } - await printClassRecords(selectedStudent, filteredRecordsForPrint, periodTitle); - } - }; - - // PDF 공유 실행 - const handleExecuteShare = async () => { - if (!selectedStudent) return; - setPrintOptionModalVisible(false); - - if (printType === 'student') { - await shareStudentProfile(selectedStudent); - } else { - if (filteredRecordsForPrint.length === 0) { - Alert.alert('알림', `${selectedStudent.name} 학생의 해당 기간 수업 일지가 없습니다.`); - return; - } - await shareClassRecords(selectedStudent, filteredRecordsForPrint, periodTitle); - } - }; - // 학생 목록 가져오기 const loadStudents = async () => { setLoading(true); @@ -573,220 +466,35 @@ export default function StudentListScreen() { onClose={() => setResumeModalVisible(false)} /> - {/* 인쇄 및 PDF 공유 방식 선택 모달 */} - setPrintOptionModalVisible(false)} - statusBarTranslucent - > - setPrintOptionModalVisible(false)} - > - e.stopPropagation()} - > - - {selectedStudent && ( - <> - - - - {printType === 'student' ? '학생 정보 카드 출력' : '수업 일지 보고서 출력'} - - - {printType === 'student' - ? `${selectedStudent.name} 학생 대상` - : `${selectedStudent.name} 학생의 수업 기록 (${filteredRecordsForPrint.length}건 선택됨)`} - - - setPrintOptionModalVisible(false)} style={styles.modalCloseBtn}> - - - - - {/* 수업일지 보고서 출력인 경우에만 기간 선택 영역 노출 */} - {printType === 'records' && ( - <> - 출력 기간 선택 - - { - setPeriodFilter('all'); - setActiveDatePicker(null); - }} - > - - 전체 기간 - - - - { - setPeriodFilter('current_month'); - setActiveDatePicker(null); - }} - > - - 이번 달 - - - - { - setPeriodFilter('1m'); - setActiveDatePicker(null); - }} - > - - 최근 1개월 - - - - { - setPeriodFilter('3m'); - setActiveDatePicker(null); - }} - > - - 최근 3개월 - - - - { - setPeriodFilter('custom'); - setActiveDatePicker(null); - }} - > - - 직접 설정 - - - - - {/* 직접 설정 선택 시 시작일 ~ 종료일 선택기 */} - {periodFilter === 'custom' && ( - - - setActiveDatePicker(activeDatePicker === 'start' ? null : 'start')} - > - 시작일 - {customStartDate} - - - ~ - - setActiveDatePicker(activeDatePicker === 'end' ? null : 'end')} - > - 종료일 - {customEndDate} - - - - {/* 날짜 선택 인라인 캘린더 */} - {activeDatePicker && ( - - - - {activeDatePicker === 'start' ? '시작 날짜 선택' : '종료 날짜 선택'} - - - { - if (activeDatePicker === 'start') { - setCustomStartDate(day.dateString); - if (day.dateString > customEndDate) { - setCustomEndDate(day.dateString); - } - } else { - setCustomEndDate(day.dateString); - if (day.dateString < customStartDate) { - setCustomStartDate(day.dateString); - } - } - setActiveDatePicker(null); - }} - theme={{ - selectedDayBackgroundColor: theme.colors.primary, - todayTextColor: theme.colors.primary, - arrowColor: theme.colors.primary, - }} - /> - - )} - - )} - - {/* 선택된 대상 요약 배너 */} - - - - 조회: {periodTitle} ({filteredRecordsForPrint.length}회차 기록) - - - - )} - - {/* 옵션 1: 무선/유선 프린터로 인쇄 */} - - - {/* 옵션 2: PDF 파일 공유 (카톡/메시지) */} - - - )} - - - - + onClose={() => setPrintOptionModalVisible(false)} + printType={printType} + student={selectedStudent} + records={selectedStudentRecords} + onPrintStudent={(stud) => printStudentProfile(stud)} + onShareStudent={(stud) => shareStudentProfile(stud)} + onPrintRecords={(stud, recs, title) => { + if (!recs.length) { + Alert.alert('알림', `${stud?.name || '학생'} 학생의 해당 기간 수업 일지가 없습니다.`); + return; + } + printClassRecords(stud, recs, title); + }} + onShareRecords={(stud, recs, title) => { + if (!recs.length) { + Alert.alert('알림', `${stud?.name || '학생'} 학생의 해당 기간 수업 일지가 없습니다.`); + return; + } + shareClassRecords(stud, recs, title); + }} + /> ); } -const styles = StyleSheet.create({ +const createStyles = (theme) => StyleSheet.create({ container: { flex: 1, backgroundColor: theme.colors.surfaceVariant, @@ -928,129 +636,12 @@ const styles = StyleSheet.create({ modalCloseBtn: { padding: 4, }, - filterSectionLabel: { - fontSize: 13, - fontWeight: '700', - color: theme.colors.textPrimary, - marginBottom: 10, - }, - filterChipGroup: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 8, - marginBottom: 14, - }, - filterChip: { - paddingHorizontal: 12, - paddingVertical: 7, - borderRadius: 20, - backgroundColor: theme.colors.surfaceVariant, - borderWidth: 1, - borderColor: 'transparent', - }, - filterChipActive: { - backgroundColor: '#EFF6FF', - borderColor: theme.colors.primary, - }, - filterChipText: { - fontSize: 13, - color: theme.colors.textSecondary, - fontWeight: '600', - }, - filterChipTextActive: { - color: theme.colors.primary, - fontWeight: '700', - }, - summaryBadgeBox: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: theme.colors.secondaryContainer, - paddingHorizontal: 12, - paddingVertical: 8, - borderRadius: 8, - marginBottom: 16, - }, - summaryBadgeText: { - fontSize: 12.5, - color: theme.colors.primary, - fontWeight: '600', - }, - customDateContainer: { - backgroundColor: theme.colors.surfaceVariant + '60', - padding: 12, - borderRadius: 12, - marginBottom: 14, - borderWidth: 1, - borderColor: theme.colors.outline + '40', - }, - customDateRow: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - }, - customDateBtn: { - flex: 1, - backgroundColor: theme.colors.white, - paddingVertical: 8, - paddingHorizontal: 10, - borderRadius: 8, - borderWidth: 1, - borderColor: theme.colors.outline, - alignItems: 'center', - }, - customDateBtnActive: { - borderColor: theme.colors.primary, - backgroundColor: '#EFF6FF', - }, - customDateLabel: { - fontSize: 11, - color: theme.colors.textSecondary, - marginBottom: 2, - fontWeight: '600', - }, - customDateText: { - fontSize: 13, - fontWeight: '700', - color: theme.colors.textPrimary, - }, - customDateTilde: { - fontSize: 16, - fontWeight: 'bold', - color: theme.colors.textSecondary, - marginHorizontal: 8, - }, - inlineCustomCalendar: { - marginTop: 10, - backgroundColor: theme.colors.white, - borderRadius: 10, - padding: 8, - borderWidth: 1, - borderColor: theme.colors.outline + '40', - }, - inlineCustomCalendarHeader: { - paddingBottom: 6, - borderBottomWidth: 1, - borderBottomColor: theme.colors.surfaceVariant, - marginBottom: 6, - alignItems: 'center', - }, - inlineCustomCalendarTitle: { - fontSize: 12, - fontWeight: '700', - color: theme.colors.primary, - }, searchIcon: { marginRight: 8, }, addBtnIcon: { marginRight: 4, }, - modalScrollContent: { - paddingBottom: 10, - }, - summaryBadgeIcon: { - marginRight: 6, - }, tabContainer: { flexDirection: 'row', backgroundColor: theme.colors.surfaceVariant + '70', diff --git a/src/screens/WeeklyPlanScreen.js b/src/screens/WeeklyPlanScreen.js index 5ea208f..0863b80 100644 --- a/src/screens/WeeklyPlanScreen.js +++ b/src/screens/WeeklyPlanScreen.js @@ -8,25 +8,21 @@ import { ScrollView, SafeAreaView, ActivityIndicator, - Modal, Alert, Linking, Platform, - KeyboardAvoidingView, Keyboard, - useWindowDimensions, } from 'react-native'; import { Feather, MaterialCommunityIcons } from '@expo/vector-icons'; import { useNavigation, useIsFocused } from '@react-navigation/native'; import { Database } from '../database/Database'; -import { getMondayOfWeek, getDateFromMondayOffset } from '../utils/dateUtils'; +import { getMondayOfWeek, getDateFromMondayOffset, getTodayFormatted } from '../utils/dateUtils'; import { formatPhoneInfo } from '../utils/stringUtils'; import { printWeeklyReport, shareWeeklyReport, shareWeeklyReportDocx, shareWeeklyReportHwpx } from '../services/PrintService'; 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'; +import ScheduleEditModal from '../components/modals/ScheduleEditModal'; const DAY_LABELS = ['월', '화', '수', '목', '금', '토', '일']; @@ -50,8 +46,8 @@ const TIME_SLOTS = [ * @returns {React.JSX.Element} 주간 시간표 화면 요소 */ export default function WeeklyPlanScreen() { - // eslint-disable-next-line no-shadow const { theme } = useTheme(); + const styles = useMemo(() => createStyles(theme), [theme]); const navigation = useNavigation(); const isFocused = useIsFocused(); @@ -65,7 +61,11 @@ export default function WeeklyPlanScreen() { // 뷰 모드: 'table' (주간 전체 타임테이블) | 'timeline' (요일별 상세 타임라인) const [viewMode, setViewMode] = useState('table'); - const [selectedDayOffset, setSelectedDayOffset] = useState(0); // 0(월) ~ 6(일) + const [selectedDayOffset, setSelectedDayOffset] = useState(() => { + const d = new Date().getDay(); + return d === 0 ? 6 : d - 1; + }); // 0(월) ~ 6(일) + const todayStr = useMemo(() => getTodayFormatted(), []); // 하단 부가 업무 섹션 접기/펼치기 const [bottomNotesExpanded, setBottomNotesExpanded] = useState(false); @@ -84,16 +84,10 @@ export default function WeeklyPlanScreen() { const [formPhoneInfo, setFormPhoneInfo] = useState(''); const [formDayOfWeek, setFormDayOfWeek] = useState(1); const [formStartTime, setFormStartTime] = useState('10:00'); - const { height: screenHeight } = useWindowDimensions(); const [formStatusNote, setFormStatusNote] = useState(''); - const [studentPickerVisible, setStudentPickerVisible] = useState(false); - const [studentSearchQuery, setStudentSearchQuery] = useState(''); - 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( @@ -103,11 +97,6 @@ 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( @@ -115,7 +104,6 @@ export default function WeeklyPlanScreen() { () => { setIsKeyboardVisible(false); setKeyboardHeight(0); - focusedModalFieldRef.current = null; } ); return () => { @@ -129,14 +117,7 @@ export default function WeeklyPlanScreen() { isKeyboardVisible && { paddingBottom: Platform.OS === 'android' ? keyboardHeight + 40 : 120, }, - ], [isKeyboardVisible, keyboardHeight]); - - const editModalScrollContentDynamicStyle = useMemo(() => [ - styles.modalScrollContent, - isKeyboardVisible && { - paddingBottom: Platform.OS === 'android' ? Math.max(keyboardHeight * 0.6, 120) : 48, - }, - ], [isKeyboardVisible, keyboardHeight]); + ], [styles.mainScrollContent, isKeyboardVisible, keyboardHeight]); // 작성된 수업 일지 목록 (실시간 완료 매핑용) const [allRecords, setAllRecords] = useState([]); @@ -283,8 +264,6 @@ export default function WeeklyPlanScreen() { phoneList.push(`(전화)${homePhone}`); } setFormPhoneInfo(phoneList.join('\n')); - - setStudentPickerVisible(false); }; // 모달 내 요일 변경 핸들러 (학생이 이미 선택되어 있다면 해당 요일의 정규 수업 일정 정보 자동 연동) @@ -595,54 +574,36 @@ export default function WeeklyPlanScreen() { }); }; - // 전화 걸기 헬퍼 + // 전화 걸기 헬퍼 (다중 연락처 시 선택창 표시) const handleMakeCall = (phoneStr) => { if (!phoneStr) return; - const match = phoneStr.match(/\d{2,3}-\d{3,4}-\d{4}/); - if (match) { - Linking.openURL(`tel:${match[0]}`); - } - }; + const matches = phoneStr.match(/(?:\([^)]+\)\s*)?\d{2,3}-\d{3,4}-\d{4}/g); + if (!matches || matches.length === 0) return; - // 필터링된 학생 목록 (학생 선택 모달용 - 가나다 오름차순 정렬) - const filteredStudents = useMemo(() => { - const list = [...students].sort((a, b) => - (a.name || '').localeCompare(b.name || '', 'ko') - ); - if (!studentSearchQuery.trim()) return list; - return list.filter((s) => - (s.name || '').toLowerCase().includes(studentSearchQuery.toLowerCase()) - ); - }, [students, studentSearchQuery]); + const dialNumber = (raw) => { + const numMatch = raw.match(/\d{2,3}-\d{3,4}-\d{4}/); + if (numMatch) { + Linking.openURL(`tel:${numMatch[0]}`); + } + }; - // 키보드 상태 및 화면 크기에 따른 학생 선택 모달 동적 스타일 (키보드 가림 방지) - const studentPickerContainerStyle = useMemo(() => { - if (!isKeyboardVisible) { - return [styles.studentPickerOverlay, styles.studentPickerOverlayCentered]; - } - return [ - styles.studentPickerOverlay, - styles.studentPickerOverlayKeyboard, - ]; - }, [isKeyboardVisible]); - - const studentPickerContentStyle = useMemo(() => { - if (!isKeyboardVisible) { - return [ - styles.pickerModalContent, - styles.pickerModalContentCentered, - { maxHeight: Math.min(screenHeight * 0.75, 520) }, - ]; + if (matches.length === 1) { + dialNumber(matches[0]); + return; } - // 키보드가 올라왔을 때는 화면 상단에 안정적으로 위치하고, 키보드 바로 위까지 최대 높이 확보 - const safeTop = Platform.OS === 'android' ? 36 : 48; - const availableHeight = screenHeight - keyboardHeight - safeTop - 20; - return [ - styles.pickerModalContent, - styles.pickerModalContentKeyboard, - { maxHeight: Math.max(availableHeight, 240) }, - ]; - }, [isKeyboardVisible, keyboardHeight, screenHeight]); + + Alert.alert( + '통화할 번호 선택', + '연결할 전화번호를 선택해 주세요.', + [ + ...matches.map((item) => ({ + text: item.trim(), + onPress: () => dialNumber(item), + })), + { text: '취소', style: 'cancel' }, + ] + ); + }; // 시간표에는 재원생만 표기: 휴회 중인 학생의 수업은 시간표 렌더링에서 제외 const activeScheduleItems = useMemo(() => { @@ -798,9 +759,13 @@ export default function WeeklyPlanScreen() { {[1, 2, 3, 4, 5, 6].map((dayVal) => { const dStr = getDayDateString(dayVal); const [, m, d] = dStr.split('-'); + const isToday = dStr === todayStr; return ( - - + + {DAY_LABELS[dayVal - 1]}({parseInt(m, 10)}/{parseInt(d, 10)}) @@ -938,16 +903,33 @@ export default function WeeklyPlanScreen() { const dStr = getDayDateString(idx + 1); const [, , d] = dStr.split('-'); const isSelected = selectedDayOffset === idx; + const isToday = dStr === todayStr; return ( setSelectedDayOffset(idx)} > - + {label} - + {parseInt(d, 10)}일 @@ -1194,339 +1176,32 @@ export default function WeeklyPlanScreen() { )} - {/* ======================= 모달 1: 수업 추가 / 편집 모달 ======================= */} - setEditModalVisible(false)} - > - - { - if (isKeyboardVisible) { - Keyboard.dismiss(); - } else { - setEditModalVisible(false); - } - }} - > - true} - > - - - {selectedItem ? '수업 일정 변경 / 메모' : '새 주간 수업 등록'} - - setEditModalVisible(false)}> - - - - - - {/* 학생 선택 버튼 */} - - 학생 선택 * - { - setStudentSearchQuery(''); - setStudentPickerVisible(true); - }} - > - - {formStudentName || '학생 주소록에서 선택하기'} - - - - - - {/* 요일 및 시간 선택 */} - - - 요일 - - {DAY_LABELS.map((d, idx) => ( - handleDaySelect(idx + 1)} - > - - {d} - - - ))} - - - - - {/* 시작 시간 */} - - 시작 시간 * - setTimePickerVisible(true)} - > - - ⏰ {formStartTime || '10:00'} - - - - - - {/* 과목 */} - - 수업 과목 / 과정 - - - - {/* 주소 및 연락처 */} - - 방문 주소 - { - focusedModalFieldRef.current = 'bottom'; - setTimeout(() => { - editModalScrollRef.current?.scrollTo({ y: 160, animated: true }); - }, 120); - }} - placeholder="거주지 주소" - placeholderTextColor={theme.colors.outline} - multiline={true} - /> - - - - 연락처 정보 - { - focusedModalFieldRef.current = 'bottom'; - setTimeout(() => { - editModalScrollRef.current?.scrollToEnd({ animated: true }); - }, 120); - }} - placeholder="(모)010-0000-0000 (본)010-0000-0000" - placeholderTextColor={theme.colors.outline} - multiline={true} - /> - - - {/* 변동 메모 및 프리셋 */} - - 이번 주 특이사항 / 변동 메모 (PDF에 인쇄됨) - - {['이번주만', '휴일', '아픔', '(30분 수업)'].map((chip) => ( - setFormStatusNote(chip)} - > - {chip} - - ))} - - { - focusedModalFieldRef.current = 'bottom'; - setTimeout(() => { - editModalScrollRef.current?.scrollToEnd({ animated: true }); - }, 120); - }} - placeholder="예: => 이번주만, => 보강" - placeholderTextColor={theme.colors.outline} - /> - - - - {/* 하단 액션 버튼 (항상 화면 하단에 고정 표시) */} - - {selectedItem && ( - handleDeleteScheduleItem(selectedItem.id)} - > - - 이번주 제외 - - )} - - {selectedItem && ( - handleGoToClassRecord(selectedItem)} - > - - - {getMatchedRecord(selectedItem) ? '일지 수정' : '일지 작성'} - - - )} - - - - 저장하기 - - - - - - - - {/* ======================= 모달 2: 학생 선택 팝업 ======================= */} - { - if (isKeyboardVisible) { - Keyboard.dismiss(); - } else { - setStudentPickerVisible(false); - } - }} - statusBarTranslucent - > - - {/* 바깥 배경 터치 시 키보드 닫기 또는 모달 닫기 */} - { - if (isKeyboardVisible) { - Keyboard.dismiss(); - } else { - setStudentPickerVisible(false); - } - }} - /> - - true}> - - - - 학생 선택 - - { - Keyboard.dismiss(); - setStudentPickerVisible(false); - }} - hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }} - > - - - - - - - - {studentSearchQuery.length > 0 && ( - setStudentSearchQuery('')} - hitSlop={{ top: 6, bottom: 6, left: 6, right: 6 }} - > - - - )} - - - - {filteredStudents.length === 0 ? ( - - - - {studentSearchQuery ? '일치하는 학생이 없습니다.' : '등록된 학생이 없습니다.'} - - - ) : ( - filteredStudents.map((s) => ( - { - Keyboard.dismiss(); - handleSelectStudent(s); - }} - > - - {s.name} - - {s.school_grade || '학년 미지정'} | {s.study_method || '방문'} - - - - - )) - )} - - - - + onClose={() => setEditModalVisible(false)} + selectedItem={selectedItem} + dayLabels={DAY_LABELS} + students={students} + formStudentName={formStudentName} + formDayOfWeek={formDayOfWeek} + formStartTime={formStartTime} + formSubject={formSubject} + formAddress={formAddress} + formPhoneInfo={formPhoneInfo} + formStatusNote={formStatusNote} + onChangeSubject={setFormSubject} + onChangeAddress={setFormAddress} + onChangePhoneInfo={setFormPhoneInfo} + onChangeStatusNote={setFormStatusNote} + onChangeStartTime={setFormStartTime} + onSelectDay={handleDaySelect} + onSelectStudent={handleSelectStudent} + onSave={handleSaveScheduleItem} + onDelete={() => selectedItem && handleDeleteScheduleItem(selectedItem.id)} + matchedRecord={selectedItem ? getMatchedRecord(selectedItem) : null} + onGoToClassRecord={() => selectedItem && handleGoToClassRecord(selectedItem)} + /> {/* ======================= 모달 3: 전화관리 추가 모달 ======================= */} shareWeeklyReportHwpx(activeWeeklyPlan)} /> - {/* ======================= 모달 5: 시작 시간 GUI 선택 팝업 ======================= */} - setTimePickerVisible(false)} - /> ); } -const styles = StyleSheet.create({ +const createStyles = (theme) => StyleSheet.create({ container: { flex: 1, backgroundColor: '#F8FAFC', @@ -1721,7 +1388,7 @@ const styles = StyleSheet.create({ elevation: 2, }, tableInner: { - minWidth: 460, + minWidth: 500, }, tableHeaderRow: { flexDirection: 'row', @@ -1743,18 +1410,24 @@ const styles = StyleSheet.create({ color: '#334155', }, dayHeaderCell: { - width: 68, + width: 76, paddingVertical: 8, alignItems: 'center', justifyContent: 'center', borderLeftWidth: 1, borderLeftColor: '#CBD5E1', }, + dayHeaderCellToday: { + backgroundColor: theme.colors.primary, + }, dayHeaderLabel: { fontSize: 11, fontWeight: '800', color: '#1E293B', }, + dayHeaderLabelToday: { + color: theme.colors.onPrimary, + }, tableBodyRow: { flexDirection: 'row', borderBottomWidth: 1, @@ -1776,7 +1449,7 @@ const styles = StyleSheet.create({ color: '#475569', }, scheduleCell: { - width: 68, + width: 76, minHeight: 46, borderRightWidth: 1, borderRightColor: '#E2E8F0', @@ -1811,16 +1484,16 @@ const styles = StyleSheet.create({ marginBottom: 2, }, cellItemTitle: { - fontSize: 9, + fontSize: 10.5, fontWeight: '800', color: '#1E3A8A', }, cellItemSubject: { - fontSize: 8, + fontSize: 9.5, color: '#2563EB', }, cellItemNote: { - fontSize: 7.5, + fontSize: 9, color: '#DC2626', fontWeight: '700', }, @@ -1934,6 +1607,10 @@ const styles = StyleSheet.create({ borderWidth: 1, borderColor: theme.colors.outline + '30', }, + dayTabPillToday: { + backgroundColor: theme.colors.secondaryContainer, + borderColor: theme.colors.primary, + }, dayTabPillActive: { backgroundColor: theme.colors.primary, borderColor: theme.colors.primary, @@ -1943,6 +1620,9 @@ const styles = StyleSheet.create({ fontWeight: '700', color: theme.colors.textSecondary, }, + dayTabLabelToday: { + color: theme.colors.primary, + }, dayTabLabelActive: { color: theme.colors.onPrimary, }, @@ -2276,327 +1956,6 @@ const styles = StyleSheet.create({ callDeleteBtn: { padding: 4, }, - modalOverlay: { - flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.45)', - justifyContent: 'flex-end', - }, - modalKeyboardAvoid: { - flex: 1, - }, - editModalContent: { - backgroundColor: theme.colors.white, - borderTopLeftRadius: 20, - borderTopRightRadius: 20, - paddingHorizontal: 20, - paddingTop: 20, - paddingBottom: Platform.OS === 'android' ? 56 : 36, - maxHeight: '88%', - }, - editModalContentKeyboard: { - paddingBottom: 16, - maxHeight: '94%', - }, - modalHeader: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - marginBottom: 16, - paddingBottom: 10, - borderBottomWidth: 1, - borderBottomColor: theme.colors.surfaceVariant, - }, - modalTitle: { - fontSize: 17, - fontWeight: 'bold', - color: theme.colors.textPrimary, - }, - modalSubtitle: { - fontSize: 12, - color: theme.colors.textSecondary, - marginTop: 2, - }, - modalScroll: { - flexShrink: 1, - }, - modalScrollContent: { - paddingBottom: 10, - }, - formGroup: { - marginBottom: 12, - }, - formRow: { - flexDirection: 'row', - gap: 10, - marginBottom: 12, - }, - formCol: { - gap: 4, - }, - formLabel: { - fontSize: 12, - fontWeight: '700', - color: theme.colors.textSecondary, - marginBottom: 4, - }, - reqStar: { - color: theme.colors.error, - }, - studentPickerBtn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - backgroundColor: theme.colors.surfaceVariant + '50', - borderWidth: 1, - borderColor: theme.colors.outline + '40', - borderRadius: 8, - paddingHorizontal: 12, - paddingVertical: 10, - }, - studentPickerText: { - fontSize: 14, - fontWeight: '700', - color: theme.colors.textPrimary, - }, - studentPickerPlaceholder: { - fontSize: 13, - color: theme.colors.outline, - }, - daySelectRow: { - flexDirection: 'row', - gap: 6, - }, - daySelectPill: { - width: 38, - height: 34, - borderRadius: 6, - backgroundColor: theme.colors.surfaceVariant + '70', - alignItems: 'center', - justifyContent: 'center', - }, - daySelectPillActive: { - backgroundColor: theme.colors.primary, - }, - daySelectPillText: { - fontSize: 12, - fontWeight: '700', - color: theme.colors.textSecondary, - }, - daySelectPillTextActive: { - color: theme.colors.onPrimary, - }, - formInput: { - backgroundColor: theme.colors.surfaceVariant + '40', - borderWidth: 1, - borderColor: theme.colors.outline + '40', - borderRadius: 8, - paddingHorizontal: 10, - paddingVertical: 8, - fontSize: 13, - color: theme.colors.textPrimary, - }, - presetChipRow: { - flexDirection: 'row', - flexWrap: 'wrap', - gap: 6, - marginBottom: 6, - }, - presetChip: { - backgroundColor: '#FEE2E2', - paddingHorizontal: 8, - paddingVertical: 4, - borderRadius: 6, - }, - presetChipText: { - fontSize: 10.5, - fontWeight: '700', - color: '#B91C1C', - }, - modalBtnRow: { - flexDirection: 'row', - justifyContent: 'flex-end', - alignItems: 'center', - gap: 10, - marginTop: 10, - paddingTop: 12, - borderTopWidth: 1, - borderTopColor: theme.colors.surfaceVariant, - }, - modalDeleteBtn: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - paddingHorizontal: 12, - paddingVertical: 10, - borderRadius: 8, - backgroundColor: '#FEE2E2', - }, - modalDeleteBtnText: { - fontSize: 12, - fontWeight: '700', - color: theme.colors.error, - }, - modalRecordBtn: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - paddingHorizontal: 12, - paddingVertical: 10, - borderRadius: 8, - backgroundColor: theme.colors.surfaceVariant, - }, - modalRecordBtnText: { - fontSize: 12, - fontWeight: '700', - color: theme.colors.primary, - }, - modalRecordBtnTextDone: { - color: '#16A34A', - }, - modalSaveBtn: { - flexDirection: 'row', - alignItems: 'center', - gap: 4, - paddingHorizontal: 18, - paddingVertical: 10, - borderRadius: 8, - backgroundColor: theme.colors.primary, - }, - modalSaveBtnText: { - fontSize: 13, - fontWeight: '700', - color: theme.colors.onPrimary, - }, - studentPickerOverlay: { - flex: 1, - backgroundColor: 'rgba(0, 0, 0, 0.55)', - paddingHorizontal: 18, - }, - studentPickerOverlayCentered: { - justifyContent: 'center', - alignItems: 'center', - }, - studentPickerOverlayKeyboard: { - justifyContent: 'flex-start', - paddingTop: Platform.OS === 'android' ? 36 : 48, - }, - pickerModalContent: { - width: '100%', - backgroundColor: theme.colors.white, - borderRadius: 16, - padding: 18, - shadowColor: '#000', - shadowOffset: { width: 0, height: 4 }, - shadowOpacity: 0.25, - shadowRadius: 10, - elevation: 8, - }, - pickerModalContentCentered: { - borderRadius: 16, - }, - pickerModalContentKeyboard: { - borderRadius: 16, - }, - studentPickerTitleRow: { - flexDirection: 'row', - alignItems: 'center', - gap: 8, - }, - searchBar: { - flexDirection: 'row', - alignItems: 'center', - backgroundColor: theme.colors.surfaceVariant + '60', - borderRadius: 8, - paddingHorizontal: 10, - marginBottom: 10, - }, - searchInput: { - flex: 1, - paddingVertical: 9, - paddingHorizontal: 8, - fontSize: 14, - color: theme.colors.textPrimary, - }, - studentListScroll: { - flexShrink: 1, - }, - studentListScrollContent: { - paddingBottom: 8, - }, - studentPickerEmpty: { - alignItems: 'center', - justifyContent: 'center', - paddingVertical: 32, - gap: 8, - }, - studentPickerEmptyText: { - fontSize: 13, - color: theme.colors.textSecondary, - }, - studentItemRow: { - flexDirection: 'row', - justifyContent: 'space-between', - alignItems: 'center', - paddingVertical: 12, - borderBottomWidth: 1, - borderBottomColor: theme.colors.surfaceVariant, - }, - studentItemInfo: { - flex: 1, - }, - studentItemName: { - fontSize: 14, - fontWeight: '700', - color: theme.colors.textPrimary, - }, - studentItemSub: { - fontSize: 11, - color: theme.colors.textSecondary, - marginTop: 2, - }, - callModalContent: { - backgroundColor: theme.colors.white, - borderTopLeftRadius: 20, - borderTopRightRadius: 20, - padding: 20, - }, - pdfModalContent: { - backgroundColor: theme.colors.white, - borderTopLeftRadius: 24, - borderTopRightRadius: 24, - padding: 24, - paddingBottom: 36, - }, - actionMenuItem: { - flexDirection: 'row', - alignItems: 'center', - paddingVertical: 14, - paddingHorizontal: 12, - borderRadius: theme.roundness, - backgroundColor: theme.colors.surfaceVariant + '80', - marginBottom: 12, - }, - actionIconBadge: { - width: 44, - height: 44, - borderRadius: 22, - alignItems: 'center', - justifyContent: 'center', - marginRight: 14, - }, - actionMenuTextContainer: { - flex: 1, - }, - actionMenuTitle: { - fontSize: 15, - fontWeight: '700', - color: theme.colors.textPrimary, - marginBottom: 2, - }, - actionMenuSub: { - fontSize: 12, - color: theme.colors.textSecondary, - }, mapPinIcon: { marginTop: 2, }, @@ -2606,18 +1965,6 @@ const styles = StyleSheet.create({ flex1Half: { flex: 1.5, }, - multilineInput: { - height: 50, - }, - pdfShareIconBadge: { - backgroundColor: '#E0F2FE', - }, - docxShareIconBadge: { - backgroundColor: '#EFF6FF', - }, - hwpxShareIconBadge: { - backgroundColor: '#FFF7ED', - }, cellItemBoxDone: { backgroundColor: '#DCFCE7', borderLeftColor: '#16A34A', @@ -2746,20 +2093,4 @@ const styles = StyleSheet.create({ color: '#D97706', fontWeight: '700', }, - timeSelectorBtn: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - backgroundColor: theme.colors.surfaceVariant + '50', - borderWidth: 1, - borderColor: theme.colors.outline + '40', - borderRadius: 8, - paddingHorizontal: 10, - paddingVertical: 9, - }, - timeSelectorBtnText: { - fontSize: 13.5, - fontWeight: '700', - color: theme.colors.textPrimary, - }, });