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:
83
src/components/attendance/AttendanceComplete.tsx
Normal file
83
src/components/attendance/AttendanceComplete.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
316
src/components/attendance/GoogleMap.tsx
Normal file
316
src/components/attendance/GoogleMap.tsx
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user