From 25784f326c773d311b2b19b3291fa63ff66a0af8 Mon Sep 17 00:00:00 2001 From: kth0910 Date: Mon, 22 Sep 2025 11:40:49 +0900 Subject: [PATCH 01/37] =?UTF-8?q?feat:=20=EA=B3=B5=EC=A7=80=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EC=A0=9C=EB=AA=A9=20=EA=B8=B8=EC=9D=B4=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=EC=9D=84=208=EC=9E=90=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/notices/notices-page-client.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/notices/notices-page-client.tsx b/app/notices/notices-page-client.tsx index 94e9075..ddeac26 100644 --- a/app/notices/notices-page-client.tsx +++ b/app/notices/notices-page-client.tsx @@ -442,8 +442,8 @@ export function NoticesPageClient({
- {notice.title.length > 15 - ? `${notice.title.substring(0, 15)}...` + {notice.title.length > 8 + ? `${notice.title.substring(0, 8)}...` : notice.title} {/* Mobile: Show badge inline */} From 3c045c6bac4fcad90b2a15431f4d63516b03ffd0 Mon Sep 17 00:00:00 2001 From: kth0910 Date: Mon, 22 Sep 2025 11:48:08 +0900 Subject: [PATCH 02/37] =?UTF-8?q?feat:=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20=EA=B0=9C=EC=84=A0=20?= =?UTF-8?q?=EB=B0=8F=20=ED=97=A4=EB=8D=94=20=EC=A0=9C=EB=AA=A9=20=EB=8F=99?= =?UTF-8?q?=EC=A0=81=20=EB=B3=80=EA=B2=BD=20=EA=B8=B0=EB=8A=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/bill/CaptureView.tsx | 2 +- app/bill/ListView.tsx | 13 ++--------- app/bill/ReviewView.tsx | 2 +- app/bill/page.tsx | 47 +++++++++++++++++++++------------------- components/header.tsx | 25 ++++++++++++++++++++- 5 files changed, 53 insertions(+), 36 deletions(-) diff --git a/app/bill/CaptureView.tsx b/app/bill/CaptureView.tsx index 0af6cd5..eef4e7e 100644 --- a/app/bill/CaptureView.tsx +++ b/app/bill/CaptureView.tsx @@ -228,7 +228,7 @@ const CaptureView: React.FC = ({ }, [selectedRoom, currentPhotoType, selectedYear, selectedMonth]); return ( -
+
{/* 헤더 */}
-
-

관리비 사진 관리

+

관리비 관리

{/* 년도와 월 선택기 */}
diff --git a/app/bill/ReviewView.tsx b/app/bill/ReviewView.tsx index 2920727..e03d5e8 100644 --- a/app/bill/ReviewView.tsx +++ b/app/bill/ReviewView.tsx @@ -127,7 +127,7 @@ const ReviewView: React.FC = ({ }; return ( -
+
{/* 헤더 */}
-

공지 시스템

+

{getPageTitle()}

From 48add482c2204eaf7f2c360612683991f9611352 Mon Sep 17 00:00:00 2001 From: kth0910 Date: Mon, 22 Sep 2025 16:20:36 +0900 Subject: [PATCH 03/37] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EB=B9=84=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20UI=20=EA=B0=9C=EC=84=A0=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/bill/CaptureView.tsx | 1 + app/bill/ListView.tsx | 441 ++++++++++++++++++++++-------- app/bill/ReviewView.tsx | 1 + app/bill/RoomDetailModal.tsx | 514 +++++++++++++++++++++++++++++++++++ app/bill/page.tsx | 79 ++---- app/bill/types.tsx | 12 + 6 files changed, 875 insertions(+), 173 deletions(-) create mode 100644 app/bill/RoomDetailModal.tsx diff --git a/app/bill/CaptureView.tsx b/app/bill/CaptureView.tsx index eef4e7e..e923303 100644 --- a/app/bill/CaptureView.tsx +++ b/app/bill/CaptureView.tsx @@ -13,6 +13,7 @@ const CaptureView: React.FC = ({ selectedRoom, selectedYear, selectedMonth, + selectedFloor, onBack, onSaveComplete, }) => { diff --git a/app/bill/ListView.tsx b/app/bill/ListView.tsx index d4ea4e8..18626a6 100644 --- a/app/bill/ListView.tsx +++ b/app/bill/ListView.tsx @@ -1,26 +1,39 @@ -'use client'; +"use client"; -import React, { useState, useEffect } from 'react'; -import { Camera, Eye, ArrowLeft } from 'lucide-react'; -import { useRouter } from 'next/navigation'; -import { Room, ApiRoom, CommonProps, NavigationCallbacks } from './types'; +import React, { useState, useEffect } from "react"; +import { CheckCircle, AlertTriangle, ChevronDown } from "lucide-react"; +import { + RoomPaymentInfo, + CommonProps, + NavigationCallbacks, + ApiRoom, +} from "./types"; +import { roomsApi } from "@/lib/api"; +import RoomDetailModal from "./RoomDetailModal"; interface ListViewProps extends CommonProps, NavigationCallbacks {} const ListView: React.FC = ({ - selectedRoom, selectedYear, selectedMonth, - onRoomChange, + selectedFloor, onYearChange, onMonthChange, - onNavigateToCapture, - onNavigateToReview, + onFloorChange, }) => { - const router = useRouter(); + const [filterStatus, setFilterStatus] = useState<"all" | "paid" | "unpaid">( + "all" + ); + const [isDropdownOpen, setIsDropdownOpen] = useState(false); + const [isMonthDropdownOpen, setIsMonthDropdownOpen] = useState(false); + const [roomPayments, setRoomPayments] = useState([]); const [serverRooms, setServerRooms] = useState([]); const [loadingRooms, setLoadingRooms] = useState(true); const [roomsError, setRoomsError] = useState(null); + const [isModalOpen, setIsModalOpen] = useState(false); + const [selectedRoomForModal, setSelectedRoomForModal] = useState< + string | null + >(null); // 서버에서 호실 데이터 로드 useEffect(() => { @@ -28,11 +41,7 @@ const ListView: React.FC = ({ const fetchRooms = async () => { try { setLoadingRooms(true); - const serverUrl = process.env.NEXT_PUBLIC_API_BASE_URL as string | undefined; - if (!serverUrl) throw new Error('SERVER_URL_NOT_CONFIGURED'); - const res = await fetch(`${serverUrl}/rooms`); - if (!res.ok) throw new Error(`HTTP ${res.status}`); - const data: ApiRoom[] = await res.json(); + const data = await roomsApi.getAll(); if (!cancelled) { setServerRooms(data); setRoomsError(null); @@ -40,11 +49,8 @@ const ListView: React.FC = ({ } catch (err) { if (!cancelled) { const message = err instanceof Error ? err.message : String(err); - setRoomsError( - message === 'SERVER_URL_NOT_CONFIGURED' - ? '서버 주소가 설정되지 않았습니다. .env의 NEXT_PUBLIC_SERVER_URL을 확인하세요.' - : '호실 목록을 불러오지 못했습니다.' - ); + setRoomsError("호실 목록을 불러오지 못했습니다."); + console.error("호실 로딩 실패:", message); } } finally { if (!cancelled) setLoadingRooms(false); @@ -56,110 +62,321 @@ const ListView: React.FC = ({ }; }, []); - // 호실 데이터 변환 (hasPhotos는 현재 false로 설정, 나중에 실제 데이터로 업데이트) - const rooms: Room[] = serverRooms.map(r => ({ - floor: r.floor, - roomNumber: String(r.id), - hasPhotos: false, // TODO: 실제 사진 존재 여부 확인 - })); + // 호실 데이터와 납부 상태 결합 + useEffect(() => { + if (serverRooms.length > 0) { + const generateRoomPayments = (): RoomPaymentInfo[] => { + return serverRooms.map((room) => { + const statuses: ("paid" | "unpaid" | "action_required")[] = [ + "paid", + "unpaid", + "action_required", + ]; + const randomStatus = + statuses[Math.floor(Math.random() * statuses.length)]; - const floors = Array.from(new Set(rooms.map(r => r.floor))).sort((a, b) => a - b); - const currentDate = new Date(); - const years = Array.from({length: 5}, (_, i) => currentDate.getFullYear() - i); - const months = Array.from({length: 12}, (_, i) => i + 1); + return { + roomNumber: String(room.id), + status: randomStatus, + hasPhotos: Math.random() > 0.5, + }; + }); + }; - return ( -
-
-

관리비 관리

- - {/* 년도와 월 선택기 */} -
-
- - -
-
- - + setRoomPayments(generateRoomPayments()); + } + }, [serverRooms]); + + // 통계 계산 + const totalRooms = serverRooms.length || 99; // 실제 호실 수 또는 99 (API 연결되지 않음) + const paidRooms = roomPayments.filter((r) => r.status === "paid").length; + const unpaidRooms = roomPayments.filter((r) => r.status === "unpaid").length; + + // 필터링된 호실 목록 + const filteredRooms = roomPayments.filter((room) => { + switch (filterStatus) { + case "paid": + return room.status === "paid"; + case "unpaid": + return room.status === "unpaid"; + case "all": + default: + return true; + } + }); + + // 현재 년도 기준으로 동적 생성 + const currentYear = new Date().getFullYear(); + const months = [9, 10, 11, 12, 1, 2, 3, 4, 5, 6, 7, 8]; + const days = [11, 15, 19, 17, 12, 19, 26, 19, 20, 24, 29, 76, 25, 11]; + + // 드롭다운 외부 클릭 시 닫기 + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as Element; + + if (isDropdownOpen && !target.closest(".dropdown-container")) { + setIsDropdownOpen(false); + } + + if (isMonthDropdownOpen && !target.closest(".month-dropdown-container")) { + setIsMonthDropdownOpen(false); + } + }; + + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + }, [isDropdownOpen, isMonthDropdownOpen]); + + // 호실 카드 렌더링 + const renderRoomCard = (room: RoomPaymentInfo) => { + const getCardStyle = () => { + switch (room.status) { + case "paid": + return "bg-green-100 border-green-200"; + case "unpaid": + return "bg-orange-100 border-orange-200"; + case "action_required": + return "bg-white border-gray-200"; + default: + return "bg-white border-gray-200"; + } + }; + + const getStatusIcon = () => { + switch (room.status) { + case "paid": + return ; + case "unpaid": + return ; + case "action_required": + return null; + default: + return null; + } + }; + + const getStatusText = () => { + switch (room.status) { + case "paid": + return "납부 완료"; + case "unpaid": + return "▲ 미납"; + case "action_required": + return null; + default: + return null; + } + }; + + const handleRoomClick = () => { + setSelectedRoomForModal(room.roomNumber); + setIsModalOpen(true); + }; + + return ( +
+
+
{room.roomNumber}호
+
+
+ + {room.status === "action_required" ? ( +
+ 납부 사진 보기
-
- - {selectedYear}년 {selectedMonth}월 관리비 - + ) : ( +
+ {getStatusIcon()} + {getStatusText()}
-
+ )}
- - {loadingRooms &&
호실 목록을 불러오는 중...
} - {roomsError &&
{roomsError}
} - - {floors.map(floor => ( -
-

{floor}층

-
- {rooms - .filter(room => room.floor === floor) - .map(room => ( -
onRoomChange(room.roomNumber)} + ); + }; + + return ( +
+ {/* 상단 헤더 */} +
+
+
+

관리비 관리

+ + {/* 날짜/년도 선택 */} +
+
{selectedFloor}층
+ + {/* 년도 슬라이더 */} +
+ {currentYear} +
+ + {/* 월 선택 드롭다운 */} +
+ - {selectedRoom === room.roomNumber && ( -
- - + {isMonthDropdownOpen && ( +
+
+ {months.map((month) => ( + + ))}
- )} +
+ )} +
+
+
+
+
+ + {/* 메인 콘텐츠 */} +
+ {/* 요약 통계 */} +
+

+ {selectedYear}년 {selectedMonth}월 납부 현황 +

+
+ 총 {totalRooms}세대 중 {paidRooms}세대 납부, {unpaidRooms}세대 미납 +
+
+ + {/* 필터 드롭다운 */} +
+
+ + + {isDropdownOpen && ( +
+
+ + +
- ))} +
+ )}
- ))} + + {/* 로딩/에러 상태 */} + {loadingRooms && ( +
+
+

호실 목록을 불러오는 중...

+
+ )} + + {roomsError && ( +
+

{roomsError}

+ +
+ )} + + {/* 호실 그리드 */} + {!loadingRooms && !roomsError && ( +
+ {filteredRooms.map(renderRoomCard)} +
+ )} +
+ + {/* 호실 상세 모달 */} + { + setIsModalOpen(false); + setSelectedRoomForModal(null); + }} + selectedRoom={selectedRoomForModal} + selectedYear={selectedYear} + selectedMonth={selectedMonth} + selectedFloor={selectedFloor} + onRoomChange={() => {}} + onYearChange={onYearChange} + onMonthChange={onMonthChange} + onFloorChange={onFloorChange} + />
); }; -export default ListView; \ No newline at end of file +export default ListView; diff --git a/app/bill/ReviewView.tsx b/app/bill/ReviewView.tsx index e03d5e8..c515261 100644 --- a/app/bill/ReviewView.tsx +++ b/app/bill/ReviewView.tsx @@ -13,6 +13,7 @@ const ReviewView: React.FC = ({ selectedRoom, selectedYear, selectedMonth, + selectedFloor, onBack, onNavigateToCapture, }) => { diff --git a/app/bill/RoomDetailModal.tsx b/app/bill/RoomDetailModal.tsx new file mode 100644 index 0000000..347b2ec --- /dev/null +++ b/app/bill/RoomDetailModal.tsx @@ -0,0 +1,514 @@ +'use client'; + +import React, { useState, useEffect } from 'react'; +import { X, Camera, Eye, CheckCircle, AlertTriangle, RefreshCw, Trash2 } from 'lucide-react'; +import { Photo, ServerPhotoResponse, CommonProps } from './types'; + +interface RoomDetailModalProps extends CommonProps { + isOpen: boolean; + onClose: () => void; +} + +const RoomDetailModal: React.FC = ({ + isOpen, + onClose, + selectedRoom, + selectedYear, + selectedMonth, + selectedFloor, +}) => { + const [currentPhotoType, setCurrentPhotoType] = useState<'수도' | '전기' | '가스'>('수도'); + const [previewImage, setPreviewImage] = useState(null); + const [currentMimeType, setCurrentMimeType] = useState('image/jpeg'); + const [roomPhotos, setRoomPhotos] = useState>(new Map()); + const [serverPhotos, setServerPhotos] = useState([]); + const [loadingPhotos, setLoadingPhotos] = useState(false); + const [isCaptureMode, setIsCaptureMode] = useState(false); + + // 서버에서 사진 정보 가져오기 + const fetchPhotoFromServer = async (roomId: string, type: 'water' | 'electricity' | 'gas', year: number, month: number): Promise => { + try { + const serverUrl = process.env.NEXT_PUBLIC_API_BASE_URL as string | undefined; + if (!serverUrl) { + console.error('서버 URL이 설정되지 않았습니다.'); + return null; + } + + const response = await fetch(`${serverUrl}/bill/image?roomId=${roomId}&type=${type}&year=${year}&month=${month}`); + + if (!response.ok) { + if (response.status === 404) { + return null; + } + throw new Error(`서버 요청 실패: ${response.status}`); + } + + const data: ServerPhotoResponse = await response.json(); + + const koreanType = type === 'water' ? '수도' : type === 'electricity' ? '전기' : '가스'; + return { + id: data.key, + type: koreanType, + url: data.url, + roomNumber: roomId, + timestamp: new Date(data.lastModified), + mimeType: 'image/jpeg' + }; + } catch (error) { + console.error('사진 정보 가져오기 실패:', error); + return null; + } + }; + + // 서버에서 사진들을 가져오는 useEffect + useEffect(() => { + if (selectedRoom && isOpen) { + let ignore = false; + + const fetchPhotosFromServer = async () => { + try { + setLoadingPhotos(true); + const serverUrl = process.env.NEXT_PUBLIC_API_BASE_URL as string | undefined; + if (!serverUrl) { + console.error('서버 URL이 설정되지 않았습니다.'); + return; + } + + const photoTypes: ('water' | 'electricity' | 'gas')[] = ['water', 'electricity', 'gas']; + const photoPromises = photoTypes.map(async (type) => { + try { + const response = await fetch(`${serverUrl}/bill/image?roomId=${selectedRoom}&type=${type}&year=${selectedYear}&month=${selectedMonth}`); + + if (!response.ok) { + if (response.status === 404) { + return null; + } + throw new Error(`서버 요청 실패: ${response.status}`); + } + + const data: ServerPhotoResponse = await response.json(); + + const koreanType = type === 'water' ? '수도' : type === 'electricity' ? '전기' : '가스'; + return { + id: data.key, + type: koreanType, + url: data.url, + roomNumber: selectedRoom, + timestamp: new Date(data.lastModified), + mimeType: 'image/jpeg' + } as Photo; + } catch (error) { + console.error(`${type} 사진 가져오기 실패:`, error); + return null; + } + }); + + const results = await Promise.all(photoPromises); + const validPhotos = results.filter((photo): photo is Photo => photo !== null); + + if (!ignore && validPhotos.length > 0) { + setServerPhotos(validPhotos); + } + } catch (error) { + console.error('사진 로딩 실패:', error); + } finally { + if (!ignore) { + setLoadingPhotos(false); + } + } + }; + + fetchPhotosFromServer(); + + return () => { + ignore = true; + }; + } + }, [selectedRoom, selectedYear, selectedMonth, isOpen]); + + // 파일 업로드 처리 + const handleFileUpload = (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (file) { + setCurrentMimeType(file.type || 'image/jpeg'); + + const reader = new FileReader(); + reader.onload = e => { + setPreviewImage(e.target?.result as string); + }; + reader.readAsDataURL(file); + } + }; + + // Base64를 Blob으로 변환 + const base64ToBlob = (base64: string, mimeType: string): Blob => { + const byteCharacters = atob(base64.split(',')[1]); + const byteNumbers = new Array(byteCharacters.length); + for (let i = 0; i < byteCharacters.length; i++) { + byteNumbers[i] = byteCharacters.charCodeAt(i); + } + const byteArray = new Uint8Array(byteNumbers); + return new Blob([byteArray], { type: mimeType }); + }; + + // 한국어 타입을 영어로 변환 + const getEnglishType = (koreanType: '수도' | '전기' | '가스'): 'water' | 'electricity' | 'gas' => { + const typeMap = { + '수도': 'water' as const, + '전기': 'electricity' as const, + '가스': 'gas' as const, + }; + return typeMap[koreanType]; + }; + + // MIME 타입을 파일 확장자로 변환 + const getFileExtension = (mimeType: string): string => { + const mimeToExt: Record = { + 'image/jpeg': 'jpg', + 'image/jpg': 'jpg', + 'image/png': 'png', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/bmp': 'bmp', + 'image/tiff': 'tiff', + 'image/svg+xml': 'svg', + }; + const ext = mimeToExt[mimeType.toLowerCase()]; + return ext ? ext : ''; + }; + + // S3에 사진 업로드 + const uploadPhotoToS3 = async (photo: Photo): Promise => { + try { + const serverUrl = process.env.NEXT_PUBLIC_API_BASE_URL as string | undefined; + if (!serverUrl) { + console.error('서버 URL이 설정되지 않았습니다.'); + return false; + } + + const mimeType = photo.mimeType || 'image/jpeg'; + const fileExtension = getFileExtension(mimeType); + if (fileExtension === '') { + throw new Error(`지원하지 않는 파일 형식입니다: ${mimeType}`); + } + + // Presigned URL 요청 + const presignResponse = await fetch(`${serverUrl}/bill/presign`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + contentType: mimeType, + ext: fileExtension, + roomId: photo.roomNumber, + type: getEnglishType(photo.type), + year: selectedYear, + month: selectedMonth, + }), + }); + + if (!presignResponse.ok) { + throw new Error(`Presigned URL 요청 실패: ${presignResponse.status}`); + } + + const presignData = await presignResponse.json(); + const imageBlob = base64ToBlob(photo.url, mimeType); + + // S3에 직접 업로드 + const uploadResponse = await fetch(presignData.url, { + method: 'PUT', + headers: presignData.headers, + body: imageBlob, + }); + + if (!uploadResponse.ok) { + throw new Error(`S3 업로드 실패: ${uploadResponse.status}`); + } + + return true; + } catch (error) { + console.error('사진 업로드 실패:', error); + return false; + } + }; + + // 사진 저장 + const savePhoto = async () => { + if (previewImage && selectedRoom) { + const newPhoto: Photo = { + id: Date.now().toString(), + type: currentPhotoType, + url: previewImage, + roomNumber: selectedRoom, + timestamp: new Date(), + mimeType: currentMimeType, + }; + + // 로컬 상태 업데이트 + const roomKey = `${selectedYear}-${selectedMonth}-${selectedRoom}`; + const currentPhotos = roomPhotos.get(roomKey) || []; + const updatedPhotos = [...currentPhotos.filter(p => p.type !== currentPhotoType), newPhoto]; + const newRoomPhotos = new Map(roomPhotos); + newRoomPhotos.set(roomKey, updatedPhotos); + setRoomPhotos(newRoomPhotos); + + // 백그라운드에서 S3 업로드 + uploadPhotoToS3(newPhoto).then(success => { + if (!success) { + alert(`${selectedRoom}호 ${currentPhotoType} 사진 업로드에 실패했습니다.`); + } else { + // 업로드 성공 시 서버 사진 목록 새로고침 + window.location.reload(); + } + }); + + // 다음 사진 타입으로 이동 + const photoTypes: ('수도' | '전기' | '가스')[] = ['수도', '전기', '가스']; + const currentIndex = photoTypes.indexOf(currentPhotoType); + + if (currentIndex < photoTypes.length - 1) { + const nextPhotoType = photoTypes[currentIndex + 1]; + setCurrentPhotoType(nextPhotoType); + loadExistingPhoto(nextPhotoType); + } else { + // 모든 사진 촬영 완료 + alert('호실의 사진 촬영이 완료되었습니다.'); + setIsCaptureMode(false); + setPreviewImage(null); + } + } + }; + + // 현재 타입의 기존 사진을 로드하는 함수 + const loadExistingPhoto = (photoType: '수도' | '전기' | '가스') => { + if (!selectedRoom) return; + + const roomKey = `${selectedYear}-${selectedMonth}-${selectedRoom}`; + const existingPhotos = roomPhotos.get(roomKey) || []; + const existingPhoto = existingPhotos.find(p => p.type === photoType); + + if (existingPhoto) { + setPreviewImage(existingPhoto.url); + setCurrentMimeType(existingPhoto.mimeType || 'image/jpeg'); + } else { + setPreviewImage(null); + setCurrentMimeType('image/jpeg'); + } + }; + + // 사진 타입 클릭 핸들러 + const handlePhotoTypeClick = (type: '수도' | '전기' | '가스') => { + setCurrentPhotoType(type); + loadExistingPhoto(type); + }; + + // 사진 삭제 + const deletePhoto = (photoId: string) => { + setServerPhotos(prevPhotos => prevPhotos.filter(photo => photo.id !== photoId)); + // TODO: 서버에서 사진 삭제 API 호출 + }; + + if (!isOpen) return null; + + return ( +
+
+ {/* 헤더 */} +
+
+

{selectedRoom}호 관리비

+

{selectedYear}년 {selectedMonth}월

+
+ +
+ + {/* 컨텐츠 */} +
+ {isCaptureMode ? ( + /* 촬영 모드 */ +
+
+

{currentPhotoType} 요금 촬영

+

사진을 촬영하거나 파일을 업로드하세요

+
+ + {/* 촬영 영역 */} +
+ {previewImage ? ( + Preview + ) : ( +
+ +

사진을 업로드하세요

+
+ )} +
+ + {/* 파일 업로드 */} +
+ +
+ + {/* 액션 버튼들 */} + {previewImage && ( +
+ + +
+ )} + + {/* 사진 타입 선택 */} +
+ {(['수도', '전기', '가스'] as const).map(type => ( + + ))} +
+
+ ) : ( + /* 확인 모드 */ +
+
+

등록된 사진

+ +
+ + {loadingPhotos ? ( +
+
+

사진을 불러오는 중...

+
+ ) : serverPhotos.length === 0 ? ( +
+ +

등록된 사진이 없습니다

+
+ ) : ( +
+ {(['수도', '전기', '가스'] as const).map(type => { + const photo = serverPhotos.find(p => p.type === type); + + return ( +
+
+ {type} 요금 +
+ + {photo ? ( + <> +
+ {`${type} +
+
+ + {new Date(photo.timestamp).toLocaleString('ko-KR')} + +
+ + +
+
+ + ) : ( +
+ +

사진 없음

+ +
+ )} +
+ ); + })} +
+ )} +
+ )} +
+
+
+ ); +}; + +export default RoomDetailModal; diff --git a/app/bill/page.tsx b/app/bill/page.tsx index 0320916..a6fd5bd 100644 --- a/app/bill/page.tsx +++ b/app/bill/page.tsx @@ -1,85 +1,42 @@ -'use client'; +"use client"; -import React, { useState } from 'react'; -import { Layout } from '@/components/layout'; -import { ViewMode } from './types'; -import ListView from './ListView'; -import CaptureView from './CaptureView'; -import ReviewView from './ReviewView'; +import React, { useState } from "react"; +import { Layout } from "@/components/layout"; +import ListView from "./ListView"; const Bill: React.FC = () => { - const [viewMode, setViewMode] = useState('list'); const [selectedRoom, setSelectedRoom] = useState(null); - const [selectedYear, setSelectedYear] = useState(new Date().getFullYear()); - const [selectedMonth, setSelectedMonth] = useState(new Date().getMonth() + 1); - - // 네비게이션 핸들러들 - const handleNavigateToCapture = (roomNumber: string) => { - setSelectedRoom(roomNumber); - setViewMode('capture'); - }; - - const handleNavigateToReview = (roomNumber: string) => { - setSelectedRoom(roomNumber); - setViewMode('review'); - }; - - const handleBack = () => { - setViewMode('list'); - setSelectedRoom(null); - }; - - const handleSaveComplete = () => { - setViewMode('list'); - setSelectedRoom(null); - }; - - const handleNavigateToCaptureFromReview = (roomNumber: string, photoType: '수도' | '전기' | '가스') => { - setSelectedRoom(roomNumber); - setViewMode('capture'); - // TODO: photoType을 CaptureView에 전달하는 방법 구현 - }; + const [selectedYear, setSelectedYear] = useState( + new Date().getFullYear() + ); + const [selectedMonth, setSelectedMonth] = useState( + new Date().getMonth() + 1 + ); + const [selectedFloor, setSelectedFloor] = useState(1); // 공통 Props const commonProps = { selectedRoom, selectedYear, selectedMonth, + selectedFloor, onRoomChange: setSelectedRoom, onYearChange: setSelectedYear, onMonthChange: setSelectedMonth, + onFloorChange: setSelectedFloor, }; - // 네비게이션 콜백들 + // 더미 네비게이션 콜백들 (모달에서 사용하지 않음) const navigationCallbacks = { - onNavigateToCapture: handleNavigateToCapture, - onNavigateToReview: handleNavigateToReview, - onBack: handleBack, + onNavigateToCapture: () => {}, + onNavigateToReview: () => {}, + onBack: () => {}, }; return (
- {viewMode === 'list' && ( - - )} - {viewMode === 'capture' && selectedRoom && ( - - )} - {viewMode === 'review' && selectedRoom && ( - - )} +
); diff --git a/app/bill/types.tsx b/app/bill/types.tsx index aa07767..3a249cb 100644 --- a/app/bill/types.tsx +++ b/app/bill/types.tsx @@ -43,7 +43,19 @@ export interface CommonProps { selectedRoom: string | null; selectedYear: number; selectedMonth: number; + selectedFloor: number; onRoomChange: (roomNumber: string | null) => void; onYearChange: (year: number) => void; onMonthChange: (month: number) => void; + onFloorChange: (floor: number) => void; +} + +// 납부 상태 타입 +export type PaymentStatus = 'paid' | 'unpaid' | 'action_required'; + +// 호실 납부 정보 타입 +export interface RoomPaymentInfo { + roomNumber: string; + status: PaymentStatus; + hasPhotos: boolean; } \ No newline at end of file From e9ed2b855835898528b32b2fd56ef1ba5d74b5ca Mon Sep 17 00:00:00 2001 From: kth0910 Date: Mon, 22 Sep 2025 16:31:20 +0900 Subject: [PATCH 04/37] =?UTF-8?q?feat:=20=EC=82=AC=EC=9D=B4=EB=93=9C?= =?UTF-8?q?=EB=B0=94=20=EB=84=88=EB=B9=84=20=EB=B0=8F=20=ED=85=8D=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=ED=81=AC=EA=B8=B0=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/globals.css | 42 +++++++++++++++++++++ app/notices/notices-page-client.tsx | 54 +++++++++++++-------------- components/notices/notice-preview.tsx | 18 ++++----- components/sidebar.tsx | 6 +-- 4 files changed, 81 insertions(+), 39 deletions(-) diff --git a/app/globals.css b/app/globals.css index 0ad6f00..de22cae 100644 --- a/app/globals.css +++ b/app/globals.css @@ -124,6 +124,48 @@ font-size: var(--font-size-base); /* 반응형 기본 폰트 사이즈 */ } + /* 모바일 400px 브레이크포인트 */ + @media (max-width: 400px) { + .text-xs { + font-size: 0.75rem; /* 12px */ + } + .text-sm { + font-size: 0.875rem; /* 14px */ + } + .text-base { + font-size: 1rem; /* 16px */ + } + .h-6 { + height: 1.5rem; /* 24px */ + } + .h-7 { + height: 1.75rem; /* 28px */ + } + .h-8 { + height: 2rem; /* 32px */ + } + .w-6 { + width: 1.5rem; /* 24px */ + } + .w-7 { + width: 1.75rem; /* 28px */ + } + .w-8 { + width: 2rem; /* 32px */ + } + .p-2 { + padding: 0.5rem; /* 8px */ + } + .px-2 { + padding-left: 0.5rem; /* 8px */ + padding-right: 0.5rem; /* 8px */ + } + .py-1 { + padding-top: 0.25rem; /* 4px */ + padding-bottom: 0.25rem; /* 4px */ + } + } + /* 기본 HTML 요소들 - QHD와 FHD 반응형 폰트 사이즈 */ h1, h2, diff --git a/app/notices/notices-page-client.tsx b/app/notices/notices-page-client.tsx index ddeac26..8e82523 100644 --- a/app/notices/notices-page-client.tsx +++ b/app/notices/notices-page-client.tsx @@ -251,8 +251,8 @@ export function NoticesPageClient({ > - - + + 공지 작성 @@ -263,7 +263,7 @@ export function NoticesPageClient({ > {/* Title */}
-
@@ -287,7 +287,7 @@ export function NoticesPageClient({ /> @@ -295,7 +295,7 @@ export function NoticesPageClient({ {/* Content */}
-