feat: 신규 페이지 구현 및 HR/설정 기능 개선

신규 페이지:
- 회계관리: 거래처, 예상비용, 청구서, 발주서
- 게시판: 공지사항, 자료실, 커뮤니티
- 고객센터: 문의/FAQ
- 설정: 계정, 알림, 출퇴근, 팝업, 구독, 결제내역
- 리포트 (차트 시각화)
- 개발자 테스트 URL 페이지

기능 개선:
- HR 직원관리/휴가관리/카드관리 강화
- IntegratedListTemplateV2 확장
- AuthenticatedLayout 패딩 표준화
- 로그인 페이지 UI 개선

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-12-19 19:12:34 +09:00
parent d742c0ce26
commit c6b605200d
213 changed files with 32644 additions and 775 deletions

View File

@@ -0,0 +1,83 @@
'use client';
import { Check } from 'lucide-react';
import { Button } from '@/components/ui/button';
interface AttendanceCompleteProps {
type: 'check-in' | 'check-out';
time: string;
date: string;
location: string;
onConfirm: () => void;
}
export default function AttendanceComplete({
type,
time,
date,
location,
onConfirm,
}: AttendanceCompleteProps) {
const title = type === 'check-in' ? '출근 완료' : '퇴근 완료';
const headerTitle = type === 'check-in' ? '출근하기' : '퇴근하기';
return (
<div className="flex flex-col h-full">
{/* 헤더 타이틀 */}
<div className="text-center py-4 border-b">
<h1 className="text-lg font-semibold">{headerTitle}</h1>
</div>
{/* 완료 내용 */}
<div className="flex-1 flex flex-col items-center justify-center px-6">
{/* 체크 아이콘 */}
<div className="w-20 h-20 rounded-full border-2 border-gray-300 flex items-center justify-center mb-6">
<Check className="w-10 h-10 text-gray-600" strokeWidth={2} />
</div>
{/* 완료 텍스트 */}
<h2 className="text-xl font-bold text-red-500 mb-2">{title}</h2>
{/* 시간 */}
<p className="text-2xl font-semibold text-gray-800 mb-4">{time}</p>
{/* 날짜 */}
<p className="text-gray-500 mb-6">{date}</p>
{/* 위치 */}
<div className="flex items-center text-gray-500">
<svg
className="w-4 h-4 mr-1"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
/>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"
/>
</svg>
<span>{location}</span>
</div>
</div>
{/* 확인 버튼 */}
<div className="p-4">
<Button
onClick={onConfirm}
className="w-full h-12 bg-gray-800 hover:bg-gray-900 text-white rounded-lg text-base font-medium"
>
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,316 @@
'use client';
import { useEffect, useRef, useState, useCallback } from 'react';
interface GoogleMapProps {
siteLocation: {
lat: number;
lng: number;
radius: number;
name: string;
};
onDistanceChange?: (distance: number, isInRange: boolean) => void;
}
// Window에 initGoogleMap 콜백 추가
declare global {
interface Window {
initGoogleMap?: () => void;
googleMapsLoading?: boolean;
}
}
export default function GoogleMap({ siteLocation, onDistanceChange }: GoogleMapProps) {
const mapRef = useRef<HTMLDivElement>(null);
const mapInstanceRef = useRef<google.maps.Map | null>(null);
const markerRef = useRef<google.maps.Marker | null>(null);
const circleRef = useRef<google.maps.Circle | null>(null);
const [mounted, setMounted] = useState(false);
const [isLoaded, setIsLoaded] = useState(false);
const [error, setError] = useState<string | null>(null);
// 클라이언트 마운트 확인
useEffect(() => {
setMounted(true);
}, []);
// 거리 계산 함수 (Haversine formula)
const calculateDistance = useCallback(
(lat1: number, lon1: number, lat2: number, lon2: number): number => {
const R = 6371000; // 지구 반지름 (미터)
const dLat = toRad(lat2 - lat1);
const dLon = toRad(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRad(lat1)) *
Math.cos(toRad(lat2)) *
Math.sin(dLon / 2) *
Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
},
[]
);
// 구글맵 스크립트 로드 (마운트 후에만)
useEffect(() => {
if (!mounted) return;
const apiKey = process.env.NEXT_PUBLIC_GOOGLE_MAPS_API_KEY;
console.log('[GoogleMap] API Key 존재:', !!apiKey);
if (!apiKey) {
setError('구글맵 API 키가 설정되지 않았습니다.');
return;
}
// 이미 로드된 경우
if (window.google && window.google.maps) {
console.log('[GoogleMap] 이미 로드됨');
setIsLoaded(true);
return;
}
// 로딩 중인 경우 (중복 로드 방지)
if (window.googleMapsLoading) {
console.log('[GoogleMap] 이미 로딩 중...');
// 로딩 완료 대기
const checkLoaded = setInterval(() => {
if (window.google && window.google.maps) {
clearInterval(checkLoaded);
setIsLoaded(true);
}
}, 100);
return () => clearInterval(checkLoaded);
}
// 기존 스크립트 태그 확인 (중복 방지)
const existingScript = document.querySelector('script[src*="maps.googleapis.com"]');
if (existingScript) {
console.log('[GoogleMap] 기존 스크립트 발견, 로드 대기');
const checkLoaded = setInterval(() => {
if (window.google && window.google.maps) {
clearInterval(checkLoaded);
setIsLoaded(true);
}
}, 100);
return () => clearInterval(checkLoaded);
}
// 로딩 시작 플래그
window.googleMapsLoading = true;
// 콜백 함수 등록
window.initGoogleMap = () => {
console.log('[GoogleMap] 초기화 콜백 실행됨');
window.googleMapsLoading = false;
setIsLoaded(true);
};
const script = document.createElement('script');
script.src = `https://maps.googleapis.com/maps/api/js?key=${apiKey}&callback=initGoogleMap`;
script.async = true;
script.defer = true;
script.onerror = (e) => {
console.error('[GoogleMap] 스크립트 로드 실패:', e);
window.googleMapsLoading = false;
setError('구글맵 스크립트 로드 실패');
};
document.head.appendChild(script);
console.log('[GoogleMap] 스크립트 태그 추가됨');
return () => {
// cleanup - 스크립트는 제거하지 않음 (다른 컴포넌트에서 사용 가능)
};
}, [mounted]);
// 지도 초기화
useEffect(() => {
if (!isLoaded || !mapRef.current || !window.google) return;
console.log('[GoogleMap] 지도 초기화 시작');
const map = new window.google.maps.Map(mapRef.current, {
center: { lat: siteLocation.lat, lng: siteLocation.lng },
zoom: 17,
disableDefaultUI: true,
zoomControl: true,
mapTypeControl: false,
streetViewControl: false,
fullscreenControl: false,
});
mapInstanceRef.current = map;
// 100m 반경 원 그리기 (파란색)
const circle = new window.google.maps.Circle({
map: map,
center: { lat: siteLocation.lat, lng: siteLocation.lng },
radius: siteLocation.radius,
strokeColor: '#3B82F6',
strokeOpacity: 0.8,
strokeWeight: 2,
fillColor: '#3B82F6',
fillOpacity: 0.15,
});
circleRef.current = circle;
// 현장 중심 마커 (파란색)
new window.google.maps.Marker({
map: map,
position: { lat: siteLocation.lat, lng: siteLocation.lng },
icon: {
path: window.google.maps.SymbolPath.CIRCLE,
scale: 8,
fillColor: '#3B82F6',
fillOpacity: 1,
strokeColor: '#FFFFFF',
strokeWeight: 2,
},
});
console.log('[GoogleMap] 지도 초기화 완료');
}, [isLoaded, siteLocation]);
// GPS 위치 추적
useEffect(() => {
if (!isLoaded || !mapInstanceRef.current || !window.google) return;
if (!navigator.geolocation) {
setError('GPS를 지원하지 않는 브라우저입니다.');
return;
}
console.log('[GoogleMap] GPS 추적 시작');
// 위치 업데이트 핸들러
const handlePositionUpdate = (latitude: number, longitude: number) => {
console.log('[GoogleMap] 위치 업데이트:', latitude, longitude);
// 기존 마커 제거
if (markerRef.current) {
markerRef.current.setMap(null);
}
// 내 위치 마커 생성 (빨간색)
const marker = new window.google.maps.Marker({
map: mapInstanceRef.current!,
position: { lat: latitude, lng: longitude },
icon: {
path: window.google.maps.SymbolPath.CIRCLE,
scale: 10,
fillColor: '#EF4444',
fillOpacity: 1,
strokeColor: '#FFFFFF',
strokeWeight: 2,
},
});
markerRef.current = marker;
// 거리 계산
const distance = calculateDistance(
latitude,
longitude,
siteLocation.lat,
siteLocation.lng
);
const isInRange = distance <= siteLocation.radius;
console.log('[GoogleMap] 거리:', Math.round(distance), 'm, 범위 내:', isInRange);
if (onDistanceChange) {
onDistanceChange(distance, isInRange);
}
};
const watchId = navigator.geolocation.watchPosition(
(position) => {
const { latitude, longitude } = position.coords;
handlePositionUpdate(latitude, longitude);
},
(err) => {
console.error('[GoogleMap] GPS 오류 코드:', err.code, '메시지:', err.message);
// 개발 환경 체크 (localhost, 127.0.0.1, 또는 NODE_ENV=development)
const hostname = window.location.hostname;
const isDevelopment =
hostname === 'localhost' ||
hostname === '127.0.0.1' ||
hostname.startsWith('192.168.') ||
process.env.NODE_ENV === 'development';
console.log('[GoogleMap] 환경 체크:', {
hostname,
isDevelopment,
nodeEnv: process.env.NODE_ENV,
});
// 개발 환경에서 GPS 실패 시 테스트 위치로 시뮬레이션
if (isDevelopment) {
console.log('[GoogleMap] 🎯 개발 모드: 테스트 위치로 시뮬레이션 (본사 근처 50m)');
// 본사 좌표에서 약간 떨어진 위치 (약 50m)
const testLat = siteLocation.lat + 0.0003;
const testLng = siteLocation.lng + 0.0003;
handlePositionUpdate(testLat, testLng);
return;
}
if (err.code === 1) {
setError('위치 권한이 거부되었습니다. 설정에서 위치 권한을 허용해주세요.');
} else if (err.code === 2) {
setError('위치를 확인할 수 없습니다.');
} else {
setError('위치 확인 시간이 초과되었습니다.');
}
},
{
enableHighAccuracy: true,
timeout: 15000,
maximumAge: 5000,
}
);
return () => {
navigator.geolocation.clearWatch(watchId);
};
}, [isLoaded, siteLocation, onDistanceChange, calculateDistance]);
// 마운트 전 또는 로딩 중일 때 로딩 UI 표시
if (!mounted || !isLoaded) {
return (
<div className="w-full h-full flex items-center justify-center bg-gray-100 rounded-xl">
<div className="text-center">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500 mx-auto mb-2"></div>
<p className="text-gray-500 text-sm"> ...</p>
</div>
</div>
);
}
if (error) {
return (
<div className="w-full h-full flex items-center justify-center bg-gray-100 rounded-xl">
<div className="text-center p-4">
<p className="text-red-500 text-sm">{error}</p>
</div>
</div>
);
}
return (
<div
ref={mapRef}
className="w-full h-full rounded-xl overflow-hidden"
style={{ minHeight: '300px' }}
/>
);
}
function toRad(deg: number): number {
return deg * (Math.PI / 180);
}