diff --git a/src/hooks/useSugangInwonMasterDetail.ts b/src/hooks/useSugangInwonMasterDetail.ts new file mode 100644 index 0000000..8d60909 --- /dev/null +++ b/src/hooks/useSugangInwonMasterDetail.ts @@ -0,0 +1,115 @@ +// hooks/useSugangInwonMasterDetail.ts +import { useState, useMemo } from 'react'; +import type { SugangInwon, GroupedSugangInwon, SortDirection } from '@/types/types'; + +export function useSugangInwonMasterDetailTable(sugangs: SugangInwon[]) { + // Master 정렬 및 필터링 상태 + const [sortColumnMaster, setSortColumnMaster] = useState(null); + const [sortDirectionMaster, setSortDirectionMaster] = useState('asc'); + const [filtersMaster, setFiltersMaster] = useState>>({}); + + // 확장된 행 상태 + const [expandedSet, setExpandedSet] = useState>(new Set()); + + // 데이터를 그룹화 + const groupedData: GroupedSugangInwon[] = useMemo(() => { + const map = new Map(); + sugangs.forEach((subj) => { + const list = map.get(subj.gwamokcode) || []; + list.push(subj); + map.set(subj.gwamokcode, list); + }); + return Array.from(map.entries()).map(([gwamokcode, subjects]) => ({ + gwamokcode, + master: subjects[0], + details: subjects, + })); + }, [sugangs]); + + // Master 정렬 핸들러 + const handleMasterSort = (colId: keyof SugangInwon, sortable: boolean) => { + if (!sortable) return; + if (sortColumnMaster !== colId) { + setSortColumnMaster(colId); + setSortDirectionMaster('asc'); + } else { + setSortDirectionMaster((prev) => (prev === 'asc' ? 'desc' : 'asc')); + } + }; + + // Master 필터링 핸들러 + const handleMasterCheckboxFilterChange = (colId: string, value: string) => { + setFiltersMaster((prev) => { + const newSet = new Set(prev[colId] || []); + if (newSet.has(value)) { + newSet.delete(value); + } else { + newSet.add(value); + } + return { ...prev, [colId]: newSet }; + }); + }; + + // Master 필터 초기화 핸들러 + const clearMasterFilters = (colId: string) => { + setFiltersMaster((prev) => ({ ...prev, [colId]: new Set() })); + }; + + // 행 확장/축소 핸들러 + const toggleExpand = (gwamokcode: string) => { + setExpandedSet((prev) => { + const newSet = new Set(prev); + if (newSet.has(gwamokcode)) { + newSet.delete(gwamokcode); + } else { + newSet.add(gwamokcode); + } + return newSet; + }); + }; + + // 필터링 및 정렬된 데이터 계산 + const filteredAndSorted = useMemo(() => { + // Master 필터링 + let data = groupedData.filter((group) => { + return Object.entries(filtersMaster).every(([colId, setOfVals]) => { + if (setOfVals.size === 0) return true; + const cellValue = String(group.master[colId as keyof SugangInwon] || ''); + return setOfVals.has(cellValue); + }); + }); + + // Master 정렬 + if (sortColumnMaster) { + data = [...data].sort((a, b) => { + const valA = a.master[sortColumnMaster]; + const valB = b.master[sortColumnMaster]; + + if (typeof valA === 'number' && typeof valB === 'number') { + return sortDirectionMaster === 'asc' ? valA - valB : valB - valA; + } + const strValA = String(valA || ''); + const strValB = String(valB || ''); + return sortDirectionMaster === 'asc' ? strValA.localeCompare(strValB) : strValB.localeCompare(strValA); + }); + } + + return data; + }, [groupedData, filtersMaster, sortColumnMaster, sortDirectionMaster]); + + return { + // Master 상태 및 핸들러 + sortColumnMaster, + sortDirectionMaster, + filtersMaster, + handleMasterSort, + handleMasterCheckboxFilterChange, + clearMasterFilters, + + // 기타 상태 + expandedSet, + groupedData, + filteredAndSorted, + toggleExpand, + }; +} diff --git a/src/hooks/useSugangInwonTable.ts b/src/hooks/useSugangInwonTable.ts index 32f70f0..e22187b 100644 --- a/src/hooks/useSugangInwonTable.ts +++ b/src/hooks/useSugangInwonTable.ts @@ -1,26 +1,10 @@ import { useState, useMemo } from 'react'; -import type { SugangInwon, GroupedSugangInwon, SortDirection } from '@/types/types'; +import type { SugangInwon, SortDirection } from '@/types/types'; export function useSugangInwonTable(sugangs: SugangInwon[]) { const [sortColumn, setSortColumn] = useState(null); const [sortDirection, setSortDirection] = useState('asc'); const [filters, setFilters] = useState>>({}); - const [expandedSet, setExpandedSet] = useState>(new Set()); - - const groupedData: GroupedSugangInwon[] = useMemo(() => { - const map = new Map(); - setFilters({}); - sugangs.forEach((subj) => { - const list = map.get(subj.gwamokcode) || []; - list.push(subj); - map.set(subj.gwamokcode, list); - }); - return Array.from(map.entries()).map(([gwamokcode, subjects]) => ({ - gwamokcode, - master: subjects[0], - details: subjects, - })); - }, [sugangs]); const handleSort = (colId: keyof SugangInwon, sortable: boolean) => { if (!sortable) return; @@ -48,36 +32,32 @@ export function useSugangInwonTable(sugangs: SugangInwon[]) { setFilters((prev) => ({ ...prev, [colId]: new Set() })); }; - const toggleExpand = (gwamokcode: string) => { - setExpandedSet((prev) => { - const newSet = new Set(prev); - if (newSet.has(gwamokcode)) { - newSet.delete(gwamokcode); - } else { - newSet.add(gwamokcode); - } - return newSet; - }); - }; - const filteredAndSorted = useMemo(() => { - const data = groupedData.filter((group) => { + let data = sugangs.filter((item) => { return Object.entries(filters).every(([colId, setOfVals]) => { if (setOfVals.size === 0) return true; - const cellValue = String(group.master[colId as keyof SugangInwon] || ''); + const cellValue = String(item[colId as keyof SugangInwon] || ''); return setOfVals.has(cellValue); }); }); if (sortColumn) { - data.sort((a, b) => { - const valA = a.master[sortColumn]; - const valB = b.master[sortColumn]; + data = [...data].sort((a, b) => { + const valA = a[sortColumn]; + const valB = b[sortColumn]; - // 숫자와 문자열을 구분하여 정렬 - if (typeof valA === 'number' && typeof valB === 'number') { - return sortDirection === 'asc' ? valA - valB : valB - valA; + // 숫자 변환 가능 여부 확인 + const numA = Number(valA); + const numB = Number(valB); + const isNumA = !isNaN(numA); + const isNumB = !isNaN(numB); + + if (isNumA && isNumB) { + // 둘 다 숫자인 경우 숫자 비교 + return sortDirection === 'asc' ? numA - numB : numB - numA; } + + // 둘 다 문자열이거나 하나만 숫자인 경우 문자열 비교 const strValA = String(valA || ''); const strValB = String(valB || ''); return sortDirection === 'asc' ? strValA.localeCompare(strValB) : strValB.localeCompare(strValA); @@ -85,18 +65,15 @@ export function useSugangInwonTable(sugangs: SugangInwon[]) { } return data; - }, [groupedData, filters, sortColumn, sortDirection]); + }, [sugangs, filters, sortColumn, sortDirection]); return { sortColumn, sortDirection, filters, - expandedSet, - groupedData, filteredAndSorted, handleSort, handleCheckboxFilterChange, - toggleExpand, clearAllFilters, }; } diff --git a/src/sugang_inwon/App.tsx b/src/sugang_inwon/App.tsx index 1396206..cc28ea6 100644 --- a/src/sugang_inwon/App.tsx +++ b/src/sugang_inwon/App.tsx @@ -58,7 +58,7 @@ export default function App() { }, []); return ( -
+
= (groupedDataProp: GroupedD const groupedData = groupedDataProp.groupedData; const [selectedUniversity, setSelectedUniversity] = useState(null); const [selectedFaculty, setSelectedFaculty] = useState(null); - const [subjectsData, setSubjectsData] = useState([]); const [selectedDepartment, setSelectedDepartment] = useState(null); const [selectedTrack, setSelectedTrack] = useState(null); const [searchTerm, setSearchTerm] = useState(''); @@ -37,7 +36,10 @@ const DepartmentSelector: React.FC = (groupedDataProp: GroupedD const [sugangInwon, setSugangInwon] = useState([]); + const [view, setView] = useState('master-detail'); + useEffect(() => { + if (!selectedDepartment) return; const fetchAndGroupXML = async () => { try { /** @@ -45,7 +47,11 @@ const DepartmentSelector: React.FC = (groupedDataProp: GroupedD * * */ - await fetchSugangInwonData({ code: 'L11E', setSugangInwon: setSugangInwon }); + + await fetchSugangInwonData({ + code: selectedDepartment.tcd, + setSugangInwon: setSugangInwon, + }); setLoading(false); } catch (err) { setError((err as Error).message); @@ -53,7 +59,29 @@ const DepartmentSelector: React.FC = (groupedDataProp: GroupedD } }; fetchAndGroupXML(); - }, []); + }, [selectedDepartment]); + + useEffect(() => { + if (isInitialized || !groupedData || Object.keys(groupedData).length === 0) return; + + setIsInitialized(true); + loadDataFromStorage('department', (data: string | null) => { + if (!data) data = '교양필수'; + Object.entries(groupedData).forEach(([university, faculties]) => { + Object.entries(faculties).forEach(([faculty, departments]) => { + departments.forEach((dept) => { + if (dept.tnm.toLowerCase().includes(data)) { + setSelectedDepartment({ university: university, faculty: faculty, tcd: dept.tcd, tnm: dept.tnm }); + setSelectedUniversity(university); + setSelectedFaculty(faculty); + setSelectedTrack(dept.tnm); + return; + } + }); + }); + }); + }); + }, [groupedData, isInitialized]); useEffect(() => { if (searchTerm.trim() === '') { @@ -80,27 +108,6 @@ const DepartmentSelector: React.FC = (groupedDataProp: GroupedD setSearchResults(results); }, [searchTerm, groupedData]); - useEffect(() => { - if (isInitialized || !groupedData || Object.keys(groupedData).length === 0) return; - - setIsInitialized(true); - loadDataFromStorage('department', (data: string | null) => { - if (!data) return; - Object.entries(groupedData).forEach(([university, faculties]) => { - Object.entries(faculties).forEach(([faculty, departments]) => { - departments.forEach((dept) => { - if (dept.tnm.toLowerCase().includes(data)) { - setSelectedDepartment({ university: university, faculty: faculty, tcd: dept.tcd, tnm: dept.tnm }); - setSelectedUniversity(university); - setSelectedFaculty(faculty); - setSelectedTrack(dept.tnm); - } - }); - }); - }); - }); - }, [groupedData, isInitialized]); - // 학과 검색 결과 선택 핸들러 const handleSearchSelect = (result: DepartmentSearchResult) => { setSelectedUniversity(result.university); diff --git a/src/sugang_inwon/SugangInwonMasterTable.tsx b/src/sugang_inwon/SugangInwonMasterTable.tsx new file mode 100644 index 0000000..1ffdd17 --- /dev/null +++ b/src/sugang_inwon/SugangInwonMasterTable.tsx @@ -0,0 +1,346 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { + Table, + TableBody, + TableCell, + TableHead as TableHeaderCell, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { ArrowUp, ArrowDown, ListFilter } from 'lucide-react'; +import { AnimatePresence, motion } from 'framer-motion'; +import type { SugangInwon, SugangMasterColumn, SugangDetailColumn, GroupedSugangInwon } from '@/types/types'; +import ListFilterPlus from '@/assets/filter.svg'; +import { useSugangInwonMasterDetailTable } from '@/hooks/useSugangInwonMasterDetail'; +import DetailTable from './components/SugangInwonDetailTable'; + +const masterColumns: SugangMasterColumn[] = [ + { id: 'gwamokcode', label: '과목코드', width: 65, sortable: false, filterable: false }, + { id: 'gwamokname', label: '과목명', width: 160, sortable: true, filterable: true }, + { id: 'isu', label: '이수구분', width: 80, sortable: false, filterable: true }, + { id: 'haknean', label: '학년', width: 60, sortable: true, filterable: true }, + { id: 'hakjum', label: '학점', width: 60, sortable: true, filterable: true }, +]; + +const detailColumns: SugangDetailColumn[] = [ + { id: 'juya', label: '주/야', width: 35, sortable: true, filterable: true }, + { id: 'bunban', label: '분반', width: 25, sortable: true, filterable: false }, + { id: 'profname', label: '교수', width: 40, sortable: true, filterable: true }, + { id: 'ta1', label: '타과1학년', width: 40, sortable: true, filterable: false }, + { id: 'ta2', label: '타과2학년', width: 40, sortable: true, filterable: false }, + { id: 'ta3', label: '타과3학년', width: 40, sortable: true, filterable: false }, + { id: 'ta4', label: '타과4학년', width: 40, sortable: true, filterable: false }, + { id: 'pyun', label: '편입', width: 25, sortable: true, filterable: false }, + { id: 'jahaknean', label: '자과개설학년', width: 45, sortable: true, filterable: false }, + { id: 'total', label: '총잔여인원', width: 45, sortable: true, filterable: false }, + { id: 'pre_sugang', label: '장바구니인원', width: 45, sortable: true, filterable: false }, +]; + +const SugangInwonMasterTable: React.FC<{ + sugangs: SugangInwon[]; + setLength: (length: number) => void; +}> = ({ sugangs, setLength }) => { + const { + // Master 상태 및 핸들러 + sortColumnMaster, + sortDirectionMaster, + filtersMaster, + handleMasterSort, + handleMasterCheckboxFilterChange, + clearMasterFilters, + + // 확장된 행 상태 + expandedSet, + groupedData, + filteredAndSorted, + toggleExpand, + } = useSugangInwonMasterDetailTable(sugangs); + + useEffect(() => { + setLength(filteredAndSorted.length); + }, [filteredAndSorted]); + const [searchTermsMaster, setSearchTermsMaster] = useState>({}); + + // 컬럼 너비 상태 관리 + const [columnWidths, setColumnWidths] = useState>( + masterColumns.reduce( + (acc, col) => { + acc[col.id] = col.width; + return acc; + }, + {} as Record + ) + ); + + // 드래그 핸들링을 위한 참조 + const startXRef = useRef(0); + const startWidthsRef = useRef<{ left: number; right: number }>({ left: 0, right: 0 }); + const currentResizerRef = useRef<{ leftId: string; rightId: string } | null>(null); + + const MIN_WIDTH = 50; // 최소 너비 설정 + + const handleMouseDown = (e: React.MouseEvent, leftColId: string, rightColId: string) => { + e.preventDefault(); // 드래그 시 선택 방지 + startXRef.current = e.clientX; + startWidthsRef.current = { + left: columnWidths[leftColId], + right: columnWidths[rightColId], + }; + currentResizerRef.current = { leftId: leftColId, rightId: rightColId }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; + + const handleMouseMove = (e: MouseEvent) => { + if (currentResizerRef.current) { + const deltaX = e.clientX - startXRef.current; + const { leftId, rightId } = currentResizerRef.current; + + const startLeftWidth = startWidthsRef.current.left; + const startRightWidth = startWidthsRef.current.right; + + // left 컬럼이 최소 너비 이상 유지되도록 deltaX 제한 + let adjustedDeltaX = deltaX; + if (startLeftWidth + deltaX < MIN_WIDTH) { + adjustedDeltaX = MIN_WIDTH - startLeftWidth; + } + + // right 컬럼이 최소 너비 이상 유지되도록 deltaX 제한 + if (startRightWidth - adjustedDeltaX < MIN_WIDTH) { + adjustedDeltaX = startRightWidth - MIN_WIDTH; + } + + const newLeftWidth = startLeftWidth + adjustedDeltaX; + const newRightWidth = startRightWidth - adjustedDeltaX; + + setColumnWidths((prev) => ({ + ...prev, + [leftId]: newLeftWidth, + [rightId]: newRightWidth, + })); + } + }; + + const handleMouseUp = () => { + currentResizerRef.current = null; + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + // Popover 상태 관리 (Master) + const [openPopoverMaster, setOpenPopoverMaster] = useState>({}); + + return ( +
+ {/* Master 테이블 헤더 */} +
+ + + + {/* Master 컬럼 헤더 */} + {masterColumns.map((col, index) => { + const isLastColumn = index === masterColumns.length - 1; + const nextCol = masterColumns[index + 1]; + + return ( + +
+ + {col.filterable && ( + + setOpenPopoverMaster((prev) => ({ + ...prev, + [col.id]: open, + })) + } + > + + + + setOpenPopoverMaster((prev) => ({ ...prev, [col.id]: false }))} + > +
+ { + setSearchTermsMaster((prev) => ({ ...prev, [col.id]: e.target.value })); + }} + /> +
+ {Array.from(new Set(groupedData.map((group) => group.master[col.id]))) + .filter((value) => + value.toLowerCase().includes((searchTermsMaster[col.id] || '').toLowerCase()) + ) + .map((val) => ( +
+ { + handleMasterCheckboxFilterChange(col.id, val); + }} + className="w-4 h-4" + /> + +
+ ))} +
+ + +
+
+
+ )} +
+ {/* 리사이저 핸들 */} + {!isLastColumn && nextCol && ( +
handleMouseDown(e, col.id, nextCol.id)} + /> + )} + {/* 컬럼 간 경계선을 위한 border */} + {!isLastColumn && ( +
+ )} + + ); + })} + + +
+
+ + {/* 스크롤 가능한 테이블 바디 */} +
+ + + + {filteredAndSorted.map((group: GroupedSugangInwon) => ( + + {/* Master Row */} + toggleExpand(group.gwamokcode)} + className="cursor-pointer bg-white hover:bg-gray-100" + > + {masterColumns.map((col) => ( + +
+ {group.master[col.id]} +
+
+ ))} +
+ + {/* Detail Row */} + + {expandedSet.has(group.gwamokcode) && ( + + + + + + )} + +
+ ))} +
+
+
+
+
+ ); +}; + +export default SugangInwonMasterTable; diff --git a/src/sugang_inwon/SugangInwonTable.tsx b/src/sugang_inwon/SugangInwonTable.tsx index 0038a71..fc891ee 100644 --- a/src/sugang_inwon/SugangInwonTable.tsx +++ b/src/sugang_inwon/SugangInwonTable.tsx @@ -1,443 +1,31 @@ -import React, { useState, useRef, useEffect } from 'react'; -import { - Table, - TableBody, - TableCell, - TableHead as TableHeaderCell, - TableHeader, - TableRow, -} from '@/components/ui/table'; -import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; -import { Checkbox } from '@/components/ui/checkbox'; -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; -import { ArrowUp, ArrowDown, ListFilter } from 'lucide-react'; -import { AnimatePresence, motion } from 'framer-motion'; -import { useSubjectsTable } from '@/hooks/useSubjectsTable'; -import type { SugangInwon, SugangMasterColumn, SugangDetailColumn } from '@/types/types'; -import ListFilterPlus from '@/assets/filter.svg'; -import { useSugangInwonTable } from '@/hooks/useSugangInwonTable'; - -const masterColumns: SugangMasterColumn[] = [ - { id: 'gwamokcode', label: '과목코드', width: 65, sortable: false, filterable: false }, - { id: 'gwamokname', label: '과목명', width: 160, sortable: true, filterable: true }, - { id: 'isu', label: '이수구분', width: 80, sortable: false, filterable: true }, - { id: 'haknean', label: '학년', width: 60, sortable: true, filterable: true }, - { id: 'hakjum', label: '학점', width: 60, sortable: true, filterable: true }, -]; - -const detailColumns: SugangDetailColumn[] = [ - { id: 'juya', label: '주/야', width: 35 }, - { id: 'bunban', label: '분반', width: 35 }, - { id: 'profname', label: '교수', width: 45 }, - { id: 'ta1', label: '타과1학년', width: 45 }, - { id: 'ta2', label: '타과2학년', width: 45 }, - { id: 'ta3', label: '타과3학년', width: 45 }, - { id: 'ta4', label: '타과4학년', width: 45 }, - { id: 'pyun', label: '편입', width: 45 }, - { id: 'jahaknean', label: '자과개설학년', width: 45 }, - { id: 'total', label: '총잔여인원', width: 45 }, - { id: 'pre_sugang', label: '장바구니인원', width: 45 }, -]; - -// 교시 패턴을 변환하는 함수 -const convertPeriodsInString = (input: string): string => { - const days: { [key: string]: string } = { - 월: '월', - 화: '화', - 수: '수', - 목: '목', - 금: '금', - 토: '토', - 일: '일', - }; - - const periodRegex = /([월화수목금])(\d+)(M?)~(\d+)(M?)/g; - - return input.replace(periodRegex, (match, day, startNum, startM, endNum, endM) => { - const dayFull = days[day] || day; - const start = parseInt(startNum) + 8 + (startM ? ':30' : ':00'); - const end = parseInt(endNum) + 8 + (endM ? ':30' : ':00'); - return `${dayFull}${start}~${end}`; - }); -}; - -export const SugangInwonTable: React.FC<{ sugangs: SugangInwon[] }> = ({ sugangs }) => { - const { - sortColumn, - sortDirection, - filters, - expandedSet, - groupedData, - filteredAndSorted, - handleSort, - handleCheckboxFilterChange, - toggleExpand, - clearAllFilters, - } = useSugangInwonTable(sugangs); - - const [searchTerms, setSearchTerms] = useState>({}); - // 컬럼 너비 상태 관리 - const [columnWidths, setColumnWidths] = useState>( - masterColumns.reduce( - (acc, col) => { - acc[col.id] = col.width; - return acc; - }, - {} as Record - ) - ); - - // 드래그 핸들링을 위한 참조 - const startXRef = useRef(0); - const startWidthsRef = useRef<{ left: number; right: number }>({ left: 0, right: 0 }); - const currentResizerRef = useRef<{ leftId: string; rightId: string } | null>(null); - - const MIN_WIDTH = 50; // 최소 너비 설정 - - const handleMouseDown = (e: React.MouseEvent, leftColId: string, rightColId: string) => { - e.preventDefault(); // 드래그 시 선택 방지 - startXRef.current = e.clientX; - startWidthsRef.current = { - left: columnWidths[leftColId], - right: columnWidths[rightColId], - }; - currentResizerRef.current = { leftId: leftColId, rightId: rightColId }; - - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); - }; - - const handleMouseMove = (e: MouseEvent) => { - if (currentResizerRef.current) { - const deltaX = e.clientX - startXRef.current; - const { leftId, rightId } = currentResizerRef.current; - - const startLeftWidth = startWidthsRef.current.left; - const startRightWidth = startWidthsRef.current.right; - - // left 컬럼이 최소 너비 이상 유지되도록 deltaX 제한 - let adjustedDeltaX = deltaX; - if (startLeftWidth + deltaX < MIN_WIDTH) { - adjustedDeltaX = MIN_WIDTH - startLeftWidth; - } - - // right 컬럼이 최소 너비 이상 유지되도록 deltaX 제한 - if (startRightWidth - adjustedDeltaX < MIN_WIDTH) { - adjustedDeltaX = startRightWidth - MIN_WIDTH; - } - - const newLeftWidth = startLeftWidth + adjustedDeltaX; - const newRightWidth = startRightWidth - adjustedDeltaX; - - setColumnWidths((prev) => ({ - ...prev, - [leftId]: newLeftWidth, - [rightId]: newRightWidth, - })); - } - }; - - const handleMouseUp = () => { - currentResizerRef.current = null; - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - - // Popover 상태 관리 - const [openPopover, setOpenPopover] = useState>({}); - - const openPopup = (code: string) => { - const width = 600; - const height = 600; - const left = window.innerWidth / 2 - width / 2; - const top = window.innerHeight / 2 - height / 2; - - window.open( - `https://info.hansung.ac.kr/jsp_21/student/kyomu/suupplan_main_view.jsp?code=${code}`, - '수업 계획서', - `width=${width},height=${height},top=${top},left=${left},resizable=yes,scrollbars=yes,toolbar=no,menubar=no,location=no,directories=no,status=no` - ); - }; - - // 모달 상태 관리 - const [isCourseDetailModalOpen, setIsCourseDetailModalOpen] = useState(false); - const [selectedCode, setSelectedCode] = useState(''); - - const [isCourseEvaluationModalOpen, setIsCourseEvaluationModalOpen] = useState(false); - const [selectedProfCode, setSelectedProfCode] = useState(''); - - useEffect(() => { - if (isCourseDetailModalOpen || isCourseEvaluationModalOpen) { - document.body.style.overflow = 'hidden'; - } else { - document.body.style.overflow = ''; - } - return () => { - document.body.style.overflow = ''; - }; - }, [isCourseDetailModalOpen, isCourseEvaluationModalOpen]); - +import React, { useState } from 'react'; +import type { SugangInwon } from '@/types/types'; +import TableToggle from './components/table-toggle'; +import { ViewType } from './DepartmentSelector'; +import SugangInwonMasterTable from './SugangInwonMasterTable'; +import SugangInwonTotalTable from './SugangInwonTotalTable'; + +const SugangInwonTable: React.FC<{ + sugangs: SugangInwon[]; +}> = ({ sugangs }) => { + const [view, setView] = useState('master-detail'); + const [length, setLength] = useState(0); return (
{/* 헤더 */}

잔여 인원

+
{/* 테이블 컨테이너 */} -
- {/* 테이블 헤더 */} -
- - - - {masterColumns.map((col, index) => { - const isLastColumn = index === masterColumns.length - 1; - const nextCol = masterColumns[index + 1]; - - return ( - -
- - {col.filterable && ( - - setOpenPopover((prev) => ({ - ...prev, - [col.id]: open, - })) - } - > - - - - setOpenPopover((prev) => ({ ...prev, [col.id]: false }))} - > -
- { - setSearchTerms((prev) => ({ ...prev, [col.id]: e.target.value })); - }} - /> -
- {Object.values(groupedData) - .map((group) => group.master[col.id]) - .filter((value, index, self) => self.indexOf(value) === index) - .filter((value) => - value.toLowerCase().includes((searchTerms[col.id] || '').toLowerCase()) - ) - .map((val) => ( -
- { - handleCheckboxFilterChange(col.id, val); - }} - className="w-4 h-4" - /> - -
- ))} -
- - -
-
-
- )} -
- {/* 리사이저 핸들 */} - {!isLastColumn && nextCol && ( -
handleMouseDown(e, col.id, nextCol.id)} - /> - )} - {/* 컬럼 간 경계선을 위한 border */} - {!isLastColumn && ( -
- )} - - ); - })} - - -
-
- - {/* 스크롤 가능한 테이블 바디 */} -
- - - - {filteredAndSorted.map((group) => ( - - toggleExpand(group.gwamokcode)} - className="cursor-pointer bg-white hover:bg-gray-100" - > - {masterColumns.map((col) => ( - - {col.id === 'gwamokcode' ? ( - - ) : ( -
- {group.master[col.id]} -
- )} -
- ))} -
- - {expandedSet.has(group.gwamokcode) && ( - - -
-
- - - {detailColumns.map((dc) => ( - -
- {dc.label} -
-
- ))} -
-
- - {group.details.map((detail, idx) => ( - - {detailColumns.map((dc) => ( - -
- {detail[dc.id]} -
-
- ))} -
- ))} -
-
-
- - - )} - - - ))} - - - -
-
+ {view === 'master-detail' ? ( + + ) : ( + + )}
-

{filteredAndSorted.length}개 표시 중

+

{length}개 표시 중

); diff --git a/src/sugang_inwon/SugangInwonTotalTable.tsx b/src/sugang_inwon/SugangInwonTotalTable.tsx new file mode 100644 index 0000000..02798d9 --- /dev/null +++ b/src/sugang_inwon/SugangInwonTotalTable.tsx @@ -0,0 +1,378 @@ +import React, { useState, useRef, useEffect } from 'react'; +import { + Table, + TableBody, + TableCell, + TableHead as TableHeaderCell, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { ArrowUp, ArrowDown, ListFilter } from 'lucide-react'; +import { AnimatePresence, motion } from 'framer-motion'; +import type { SugangInwon, SugangColumn } from '@/types/types'; +import ListFilterPlus from '@/assets/filter.svg'; +import { useSugangInwonTable } from '@/hooks/useSugangInwonTable'; + +const columns: SugangColumn[] = [ + { id: 'gwamokcode', label: '과목코드', width: 25, minWidth: 25, sortable: false, filterable: false }, + { id: 'gwamokname', label: '과목명', width: 50, minWidth: 35, sortable: true, filterable: true }, + { id: 'juya', label: '주야', width: 20, minWidth: 20, sortable: false, filterable: true }, + { id: 'bunban', label: '분반', width: 15, minWidth: 10, sortable: false, filterable: false }, + { id: 'profname', label: '교수', width: 25, minWidth: 20, sortable: false, filterable: true }, + { id: 'haknean', label: '학년', width: 20, minWidth: 15, sortable: false, filterable: true }, + { id: 'hakjum', label: '학점', width: 20, minWidth: 15, sortable: false, filterable: true }, + { id: 'isu', label: '이수구분', width: 20, minWidth: 15, sortable: false, filterable: true }, + { id: 'cross_juya', label: '교차여부', width: 20, minWidth: 15, sortable: false, filterable: true }, + { id: 'ta1', label: '타과1학년', width: 22, minWidth: 15, sortable: true, filterable: false }, + { id: 'ta2', label: '타과2학년', width: 22, minWidth: 15, sortable: true, filterable: false }, + { id: 'ta3', label: '타과3학년', width: 22, minWidth: 15, sortable: true, filterable: false }, + { id: 'ta4', label: '타과4학년', width: 22, minWidth: 15, sortable: true, filterable: false }, + { id: 'pyun', label: '편입', width: 17, minWidth: 10, sortable: true, filterable: false }, + { id: 'jahaknean', label: '자과개설학년', width: 30, minWidth: 20, sortable: true, filterable: false }, + { id: 'total', label: '총잔여인원', width: 30, minWidth: 20, sortable: true, filterable: false }, + { id: 'pre_sugang', label: '장바구니인원', width: 30, minWidth: 20, sortable: true, filterable: false }, +]; + +export const SugangInwonTotalTable: React.FC<{ + sugangs: SugangInwon[]; + setLength: (length: number) => void; +}> = ({ sugangs, setLength }) => { + const { + sortColumn, + sortDirection, + filters, + filteredAndSorted, + handleSort, + handleCheckboxFilterChange, + clearAllFilters, + } = useSugangInwonTable(sugangs); + + useEffect(() => { + setLength(filteredAndSorted.length); + }, [filteredAndSorted]); + const [searchTerms, setSearchTerms] = useState>({}); + + // 컬럼 가시성 상태 추가 + const [visibleColumns, setVisibleColumns] = useState>( + columns.reduce( + (acc, col) => { + acc[col.id] = true; + return acc; + }, + {} as Record + ) + ); + + const toggleColumn = (colId: string) => { + setVisibleColumns((prev) => ({ + ...prev, + [colId]: !prev[colId], + })); + }; + + // 컬럼 너비 상태 관리 + const [columnWidths, setColumnWidths] = useState>( + columns.reduce( + (acc, col) => { + acc[col.id] = col.width; + return acc; + }, + {} as Record + ) + ); + + // 드래그 핸들링을 위한 참조 + const startXRef = useRef(0); + const startWidthsRef = useRef<{ left: number; right: number }>({ left: 0, right: 0 }); + const currentResizerRef = useRef<{ leftId: string; rightId: string } | null>(null); + + // 더 이상 전역 MIN_WIDTH를 사용하지 않고, 각 컬럼의 minWidth를 사용 + const handleMouseDown = (e: React.MouseEvent, leftColId: string, rightColId: string) => { + e.preventDefault(); // 드래그 시 선택 방지 + startXRef.current = e.clientX; + startWidthsRef.current = { + left: columnWidths[leftColId], + right: columnWidths[rightColId], + }; + currentResizerRef.current = { leftId: leftColId, rightId: rightColId }; + + document.addEventListener('mousemove', handleMouseMove); + document.addEventListener('mouseup', handleMouseUp); + }; + + const handleMouseMove = (e: MouseEvent) => { + if (currentResizerRef.current) { + const deltaX = e.clientX - startXRef.current; + const { leftId, rightId } = currentResizerRef.current; + + const startLeftWidth = startWidthsRef.current.left; + const startRightWidth = startWidthsRef.current.right; + + const leftCol = columns.find((col) => col.id === leftId); + const rightCol = columns.find((col) => col.id === rightId); + + const leftMinWidth = leftCol?.minWidth || 20; + const rightMinWidth = rightCol?.minWidth || 20; + + // left 컬럼이 최소 너비 이상 유지되도록 deltaX 제한 + let adjustedDeltaX = deltaX; + if (startLeftWidth + deltaX < leftMinWidth) { + adjustedDeltaX = leftMinWidth - startLeftWidth; + } + + // right 컬럼이 최소 너비 이상 유지되도록 deltaX 제한 + if (startRightWidth - adjustedDeltaX < rightMinWidth) { + adjustedDeltaX = startRightWidth - rightMinWidth; + } + + const newLeftWidth = startLeftWidth + adjustedDeltaX; + const newRightWidth = startRightWidth - adjustedDeltaX; + + setColumnWidths((prev) => ({ + ...prev, + [leftId]: newLeftWidth, + [rightId]: newRightWidth, + })); + } + }; + + const handleMouseUp = () => { + currentResizerRef.current = null; + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + }; + + // Popover 상태 관리 + const [openPopover, setOpenPopover] = useState>({}); + + return ( +
+ {/* 테이블 헤더 */} +
+ + + + {columns.map((col, index) => { + if (!visibleColumns[col.id]) return null; // 컬럼 숨기기 + + const isLastColumn = index === columns.length - 1; + const nextCol = columns[index + 1]; + + return ( + +
+ {col.filterable &&
} + + {col.filterable && ( + + setOpenPopover((prev) => ({ + ...prev, + [col.id]: open, + })) + } + > + + + + setOpenPopover((prev) => ({ ...prev, [col.id]: false }))} + > +
+ { + setSearchTerms((prev) => ({ + ...prev, + [col.id]: e.target.value, + })); + }} + /> +
+ {Array.from(new Set(sugangs.map((subj) => subj[col.id]))) + .filter((value) => + value.toLowerCase().includes((searchTerms[col.id] || '').toLowerCase()) + ) + .map((val) => ( +
+ { + handleCheckboxFilterChange(col.id, val); + }} + className="w-4 h-4" + /> + +
+ ))} +
+ + +
+
+
+ )} +
+ {/* 리사이저 핸들 */} + {!isLastColumn && nextCol && ( +
handleMouseDown(e, col.id, nextCol.id)} + /> + )} + {!isLastColumn && ( +
+ )} + + ); + })} + + +
+
+ + {/* 스크롤 가능한 테이블 바디 */} +
+ + + + {filteredAndSorted.map((item) => ( + + {columns.map((col) => { + if (!visibleColumns[col.id]) return null; + + return ( + +
+ {item[col.id]} +
+
+ ); + })} +
+ ))} +
+
+
+
+
+ ); +}; + +export default SugangInwonTotalTable; diff --git a/src/sugang_inwon/components/SugangInwonDetailTable.tsx b/src/sugang_inwon/components/SugangInwonDetailTable.tsx new file mode 100644 index 0000000..2c1caf1 --- /dev/null +++ b/src/sugang_inwon/components/SugangInwonDetailTable.tsx @@ -0,0 +1,245 @@ +import React, { useState, useMemo } from 'react'; +import { + Table, + TableBody, + TableCell, + TableHead as TableHeaderCell, + TableHeader, + TableRow, +} from '@/components/ui/table'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { ArrowUp, ArrowDown, ListFilter } from 'lucide-react'; +import type { SugangInwon, SugangDetailColumn, GroupedSugangInwon } from '@/types/types'; +import ListFilterPlus from '@/assets/filter.svg'; + +interface DetailTableProps { + group: GroupedSugangInwon; + detailColumns: SugangDetailColumn[]; +} + +const SugangInwonDetailTable: React.FC = ({ group, detailColumns }) => { + // 독립적인 정렬 및 필터링 상태 관리 + const [sortColumn, setSortColumn] = useState(null); + const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); + const [filters, setFilters] = useState>>({}); + const [searchTerms, setSearchTerms] = useState>({}); + const [openPopover, setOpenPopover] = useState>({}); + + // 정렬 핸들러 + const handleSort = (colId: keyof SugangInwon, sortable: boolean) => { + if (!sortable) return; + if (sortColumn !== colId) { + setSortColumn(colId); + setSortDirection('asc'); + } else { + setSortDirection((prev) => (prev === 'asc' ? 'desc' : 'asc')); + } + }; + + // 필터 변경 핸들러 + const handleCheckboxFilterChange = (colId: string, value: string) => { + setFilters((prev) => { + const newSet = new Set(prev[colId] || []); + if (newSet.has(value)) { + newSet.delete(value); + } else { + newSet.add(value); + } + return { ...prev, [colId]: newSet }; + }); + }; + + // 필터 초기화 핸들러 + const clearFilters = (colId: string) => { + setFilters((prev) => ({ ...prev, [colId]: new Set() })); + }; + + // 정렬 및 필터링된 데이터 계산 + const sortedAndFilteredDetails = useMemo(() => { + let details = [...group.details]; + + // 필터링 + details = details.filter((detail) => + Object.entries(filters).every(([colId, setOfVals]) => { + if (setOfVals.size === 0) return true; + const cellValue = String(detail[colId as keyof SugangInwon] || ''); + return setOfVals.has(cellValue); + }) + ); + + // 정렬 + if (sortColumn) { + details.sort((a, b) => { + const valA = a[sortColumn]; + const valB = b[sortColumn]; + + if (typeof valA === 'number' && typeof valB === 'number') { + return sortDirection === 'asc' ? valA - valB : valB - valA; + } + const strValA = String(valA || ''); + const strValB = String(valB || ''); + return sortDirection === 'asc' ? strValA.localeCompare(strValB) : strValB.localeCompare(strValA); + }); + } + + return details; + }, [group.details, filters, sortColumn, sortDirection]); + + return ( +
+ + + + {detailColumns.map((col) => ( + +
+ + {col.filterable && ( + + setOpenPopover((prev) => ({ + ...prev, + [col.id]: open, + })) + } + > + + + + setOpenPopover((prev) => ({ ...prev, [col.id]: false }))} + > +
+ { + setSearchTerms((prev) => ({ ...prev, [col.id]: e.target.value })); + }} + /> +
+ {Array.from(new Set(group.details.map((detail) => detail[col.id as keyof SugangInwon]))) + .filter((value): value is string => typeof value === 'string') + .filter((value) => + value.toLowerCase().includes((searchTerms[col.id] || '').toLowerCase()) + ) + .map((val) => ( +
+ { + handleCheckboxFilterChange(col.id, val); + }} + className="w-4 h-4" + /> + +
+ ))} +
+ + +
+
+
+ )} +
+ {/* 컬럼 간 경계선을 위한 border */} +
+ + ))} + + + + {sortedAndFilteredDetails.map((detail, idx) => ( + + {detailColumns.map((dc) => ( + +
+ {String(detail[dc.id as keyof SugangInwon] || '')} +
+
+ ))} +
+ ))} +
+
+
+ ); +}; + +export default SugangInwonDetailTable; diff --git a/src/sugang_inwon/components/table-toggle.tsx b/src/sugang_inwon/components/table-toggle.tsx new file mode 100644 index 0000000..7894615 --- /dev/null +++ b/src/sugang_inwon/components/table-toggle.tsx @@ -0,0 +1,42 @@ +import { FolderOpen, SquareChartGantt } from 'lucide-react'; +import { motion } from 'framer-motion'; +import { ViewType } from '../DepartmentSelector'; + +interface Props { + view: ViewType; + setView: (viewType: ViewType) => void; +} + +export default function TableToggle({ view, setView }: Props) { + return ( +
+ + + +
+ ); +} diff --git a/src/types/types.ts b/src/types/types.ts index 25cf28a..30372ff 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -146,6 +146,15 @@ export interface SugangInwon { juya: string; } +export interface SugangColumn { + id: keyof SugangInwon; + label: string; + width: number; + minWidth: number; + sortable: boolean; + filterable: boolean; +} + export interface SugangMasterColumn { id: keyof SugangInwon; label: string; @@ -158,6 +167,8 @@ export interface SugangDetailColumn { id: keyof SugangInwon; label: string; width: number; + sortable: boolean; + filterable: boolean; } export interface GroupedSugangInwon {