Files
sam-kd/geoattendance/index.php

641 lines
34 KiB
PHP

<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SAM GPS 기반 출퇴근 시스템</title>
<!-- Tailwind CSS -->
<script src="https://cdn.tailwindcss.com"></script>
<!-- React & ReactDOM -->
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<!-- Babel for JSX -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<!-- Lucide Icons -->
<script src="https://unpkg.com/lucide@latest"></script>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
body { font-family: 'Inter', sans-serif; }
</style>
</head>
<body class="bg-gray-50">
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useRef } = React;
// Constants
const AttendanceType = {
CLOCK_IN: 'CLOCK_IN',
CLOCK_OUT: 'CLOCK_OUT'
};
const DEFAULT_OFFICE = {
name: "Main HQ",
latitude: 37.5665,
longitude: 126.9780,
allowedRadiusMeters: 100 // 100 meters default radius
};
// Haversine formula to calculate distance between two points in meters
const calculateDistance = (loc1, loc2) => {
const R = 6371e3; // Earth radius in meters
const lat1 = loc1.latitude * Math.PI / 180;
const lat2 = loc2.latitude * Math.PI / 180;
const deltaLat = (loc2.latitude - loc1.latitude) * Math.PI / 180;
const deltaLon = (loc2.longitude - loc1.longitude) * Math.PI / 180;
const a = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) +
Math.cos(lat1) * Math.cos(lat2) *
Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c; // Distance in meters
};
// Clock Button Component
const ClockButton = ({ lastType, isLoading, isLocationAvailable, distance, allowedRadius, onClick }) => {
const isClockInNext = !lastType || lastType === AttendanceType.CLOCK_OUT;
const isInRange = distance !== null && distance <= allowedRadius;
let buttonColor = isClockInNext
? "bg-blue-600 hover:bg-blue-700 shadow-blue-200"
: "bg-red-500 hover:bg-red-600 shadow-red-200";
if (!isLocationAvailable) {
buttonColor = "bg-gray-400 cursor-not-allowed";
} else if (!isInRange) {
buttonColor = "bg-orange-500 hover:bg-orange-600 shadow-orange-200";
}
return (
<div className="flex flex-col items-center justify-center py-8">
<button
onClick={onClick}
disabled={isLoading || !isLocationAvailable}
className={`
relative w-48 h-48 rounded-full shadow-xl flex flex-col items-center justify-center
transition-all transform hover:scale-105 active:scale-95 text-white
${buttonColor}
`}
>
{isLoading ? (
<div className="w-12 h-12 border-4 border-white border-t-transparent rounded-full animate-spin"></div>
) : (
<>
<div className="text-4xl font-bold mb-1">
{isClockInNext ? "GO" : "STOP"}
</div>
<div className="text-sm font-medium opacity-90 uppercase tracking-widest">
{isClockInNext ? "출근" : "퇴근"}
</div>
{!isInRange && isLocationAvailable && (
<div className="absolute -bottom-4 bg-orange-100 text-orange-700 px-3 py-1 rounded-full text-xs font-bold border border-orange-200 flex items-center gap-1">
<i data-lucide="map-pin-off" className="w-3 h-3"></i> 범위
</div>
)}
{isInRange && (
<div className="absolute -bottom-4 bg-green-100 text-green-700 px-3 py-1 rounded-full text-xs font-bold border border-green-200 flex items-center gap-1">
<i data-lucide="map-pin" className="w-3 h-3"></i> 범위
</div>
)}
</>
)}
</button>
<div className="mt-6 text-center space-y-1">
<p className="text-gray-500 text-sm">
{isLocationAvailable
? isInRange
? "허용된 범위 내에 있습니다."
: "사무실에서 너무 멀리 떨어져 있습니다."
: "GPS 신호 대기 중..."}
</p>
{distance !== null && (
<p className="text-xs text-gray-400 font-mono">
거리: {distance < 1000 ? `${Math.round(distance)}m` : `${(distance/1000).toFixed(2)}km`}
</p>
)}
</div>
</div>
);
};
// Stats Component (simplified)
const Stats = ({ records }) => {
const today = new Date().toDateString();
const todayRecords = records.filter(r => new Date(r.timestamp).toDateString() === today);
const clockIns = todayRecords.filter(r => r.type === AttendanceType.CLOCK_IN).length;
const clockOuts = todayRecords.filter(r => r.type === AttendanceType.CLOCK_OUT).length;
return (
<div className="grid grid-cols-2 gap-4">
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100">
<p className="text-xs text-gray-400 uppercase font-semibold">오늘</p>
<p className="text-lg font-bold text-gray-800">출근 {clockIns}</p>
</div>
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100">
<p className="text-xs text-gray-400 uppercase font-semibold">전체</p>
<p className="text-lg font-bold text-gray-800">기록 {records.length}</p>
</div>
</div>
);
};
// Main App Component
const App = () => {
const [currentLocation, setCurrentLocation] = useState(null);
const [records, setRecords] = useState([]);
const [lastRecord, setLastRecord] = useState(null);
const [office, setOffice] = useState(DEFAULT_OFFICE);
const [isLoading, setIsLoading] = useState(false);
const [errorMsg, setErrorMsg] = useState(null);
const [noticeMsg, setNoticeMsg] = useState(null);
const [distance, setDistance] = useState(null);
const [activeTab, setActiveTab] = useState('home');
const [permissionStatus, setPermissionStatus] = useState('unknown');
const [isSecure, setIsSecure] = useState(window.isSecureContext);
const [geoMode, setGeoMode] = useState('알 수 없음');
const [geoRetryCount, setGeoRetryCount] = useState(0);
const [lastLocationAt, setLastLocationAt] = useState(null);
useEffect(() => {
lucide.createIcons();
}, [activeTab, records, errorMsg, noticeMsg]);
// Load records on mount
useEffect(() => {
loadRecords();
loadOfficeConfig();
checkPermission();
}, []);
const checkPermission = async () => {
if (navigator.permissions && navigator.permissions.query) {
try {
const result = await navigator.permissions.query({ name: 'geolocation' });
setPermissionStatus(result.state);
result.onchange = () => {
setPermissionStatus(result.state);
};
} catch (error) {
console.error("Permission query failed", error);
}
}
};
// Start watching location with fallback logic
useEffect(() => {
const watchIdRef = { current: null };
const retryTimerRef = { current: null };
const profileIdxRef = { current: 0 };
const retryCountRef = { current: 0 };
const profiles = [
{
label: '고정밀(High Accuracy)',
options: { enableHighAccuracy: true, maximumAge: 0, timeout: 20000 }
},
{
label: '저전력(Low Accuracy)',
// Cached/network location can be very helpful on desktop/indoors
options: { enableHighAccuracy: false, maximumAge: 600000, timeout: 30000 }
}
];
const clearWatch = () => {
if (watchIdRef.current) {
try { navigator.geolocation.clearWatch(watchIdRef.current); } catch (e) {}
watchIdRef.current = null;
}
};
const clearRetryTimer = () => {
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
};
const scheduleRetry = (baseDelayMs) => {
clearRetryTimer();
const attempt = retryCountRef.current;
const delay = Math.min(baseDelayMs * Math.max(1, attempt), 30000);
retryTimerRef.current = setTimeout(() => {
startWatch(0);
}, delay);
};
const startWatch = (profileIdx = 0) => {
clearRetryTimer();
profileIdxRef.current = profileIdx;
setGeoMode(profiles[profileIdx]?.label || '알 수 없음');
if (!('geolocation' in navigator)) {
setErrorMsg("Geolocation is not supported by your browser.");
return;
}
clearWatch();
watchIdRef.current = navigator.geolocation.watchPosition(
(position) => {
retryCountRef.current = 0;
setGeoRetryCount(0);
const newLoc = {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy
};
setCurrentLocation(newLoc);
setLastLocationAt(Date.now());
setErrorMsg(null);
setNoticeMsg(null);
},
(err) => {
console.error(err);
// Permission denied -> hard error (no auto retry)
if (err.code === 1) {
setNoticeMsg(null);
setErrorMsg("위치 정보 권한이 거부되었습니다. 브라우저 설정에서 권한을 허용해주세요.");
return;
}
// Timeout -> try fallback profile first, then soft retry
if (err.code === 3) {
if (profileIdxRef.current < profiles.length - 1) {
setErrorMsg(null);
setNoticeMsg("정확한 위치가 지연되어 저전력(Low Accuracy) 모드로 전환합니다...");
startWatch(profileIdxRef.current + 1);
return;
}
retryCountRef.current += 1;
setGeoRetryCount(retryCountRef.current);
setErrorMsg(null);
setNoticeMsg(`위치 응답이 지연되고 있습니다. 자동 재시도 중... (${retryCountRef.current}회)`);
scheduleRetry(5000);
return;
}
// Position unavailable -> soft retry (indoors/desktop common)
if (err.code === 2) {
retryCountRef.current += 1;
setGeoRetryCount(retryCountRef.current);
setErrorMsg(null);
setNoticeMsg(`위치 정보를 사용할 수 없습니다. GPS/네트워크 상태를 확인 중... (${retryCountRef.current}회 재시도)`);
scheduleRetry(8000);
return;
}
// Unknown error -> show message, but still retry once in a while
retryCountRef.current += 1;
setGeoRetryCount(retryCountRef.current);
let msg = "알 수 없는 오류가 발생했습니다. (" + (err.message || "unknown") + ")";
if (window.location.protocol !== 'https:' && window.location.hostname !== 'localhost' && window.location.hostname !== '127.0.0.1') {
msg += " (주의: 보안 연결(HTTPS)이 아니면 위치 정보가 차단될 수 있습니다)";
}
setErrorMsg(msg);
scheduleRetry(10000);
},
profiles[profileIdx]?.options || { enableHighAccuracy: true, maximumAge: 0, timeout: 20000 }
);
};
startWatch(0); // Start with High Accuracy profile
return () => {
clearRetryTimer();
clearWatch();
};
}, []);
// Recalculate distance when location or office changes
useEffect(() => {
if (currentLocation && office) {
setDistance(calculateDistance(currentLocation, office));
}
}, [currentLocation, office]);
// Load records from API
const loadRecords = async () => {
try {
const response = await fetch('api/get_records.php');
const data = await response.json();
if (data.status === 'success') {
setRecords(data.records);
if (data.records.length > 0) {
setLastRecord(data.records[0]);
}
}
} catch (error) {
console.error('Error loading records:', error);
}
};
// Load office config from localStorage
const loadOfficeConfig = () => {
const saved = localStorage.getItem('geo_office_config');
if (saved) {
try {
setOffice(JSON.parse(saved));
} catch (e) {
console.error('Error loading office config:', e);
}
}
};
// Save office config to localStorage
const saveOfficeConfig = (config) => {
localStorage.setItem('geo_office_config', JSON.stringify(config));
};
// Handle clock action
const handleClockAction = async () => {
if (!currentLocation) return;
setIsLoading(true);
const nextType = (!lastRecord || lastRecord.type === AttendanceType.CLOCK_OUT)
? AttendanceType.CLOCK_IN
: AttendanceType.CLOCK_OUT;
const isInRange = distance !== null && distance <= office.allowedRadiusMeters;
try {
const formData = new FormData();
formData.append('type', nextType);
formData.append('lat', currentLocation.latitude);
formData.append('lng', currentLocation.longitude);
formData.append('distance', distance || 0);
formData.append('is_verified', isInRange ? 1 : 0);
const response = await fetch('api/save_record.php', {
method: 'POST',
body: formData
});
const data = await response.json();
if (data.status === 'success') {
await loadRecords(); // Reload records
} else {
alert('오류: ' + data.message);
}
} catch (error) {
console.error('Error saving record:', error);
alert('기록 저장에 실패했습니다. 다시 시도해주세요.');
} finally {
setIsLoading(false);
}
};
// Set current location as office
const handleSetCurrentLocationAsOffice = () => {
if (!currentLocation) return;
const newOffice = {
...office,
latitude: currentLocation.latitude,
longitude: currentLocation.longitude
};
saveOfficeConfig(newOffice);
setOffice(newOffice);
alert("현재 위치가 사무실로 저장되었습니다!");
};
return (
<div className="min-h-screen pb-20 max-w-md mx-auto bg-gray-50 shadow-2xl overflow-hidden relative border-x border-gray-200">
{/* Header */}
<header className="bg-white px-6 py-4 shadow-sm flex justify-between items-center sticky top-0 z-10">
<div>
<h1 className="text-xl font-bold text-gray-900 tracking-tight">SAM GeoWork</h1>
<p className="text-xs text-gray-500">SAM GPS 출퇴근 시스템</p>
</div>
<a
href="../index.php"
className="flex items-center gap-2 text-sm text-gray-600 bg-gray-100 px-3 py-1.5 rounded-full hover:bg-gray-200 transition-colors cursor-pointer"
>
<i data-lucide="building-2" className="w-4 h-4"></i>
<span className="truncate max-w-[100px]">{office.name}</span>
</a>
</header>
{/* Main Content Area */}
<main className="p-4 space-y-6">
{/* Notice Banner (soft errors / retry status) */}
{noticeMsg && (
<div className="bg-amber-50 border border-amber-200 text-amber-800 p-4 rounded-xl flex items-start gap-3 text-sm">
<i data-lucide="info" className="shrink-0 mt-0.5 w-4 h-4"></i>
<p>{noticeMsg}</p>
</div>
)}
{/* Error Banner */}
{errorMsg && (
<div className="bg-red-50 border border-red-200 text-red-600 p-4 rounded-xl flex items-start gap-3 text-sm">
<i data-lucide="alert-circle" className="shrink-0 mt-0.5 w-4 h-4"></i>
<p>{errorMsg === "Unable to retrieve location. Please enable GPS." ? "위치를 가져올 수 없습니다. GPS를 활성화해주세요." : errorMsg === "Geolocation is not supported by your browser." ? "브라우저가 위치 서비스를 지원하지 않습니다." : errorMsg}</p>
</div>
)}
{/* HOME TAB */}
{activeTab === 'home' && (
<>
{/* Clock Button Component */}
<ClockButton
lastType={lastRecord?.type}
isLoading={isLoading}
isLocationAvailable={!!currentLocation}
distance={distance}
allowedRadius={office.allowedRadiusMeters}
onClick={handleClockAction}
/>
{/* Status Card */}
<div className="grid grid-cols-2 gap-4">
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100">
<p className="text-xs text-gray-400 uppercase font-semibold">상태</p>
<p className={`text-lg font-bold ${(!lastRecord || lastRecord.type === AttendanceType.CLOCK_OUT) ? 'text-gray-600' : 'text-green-600'}`}>
{(!lastRecord || lastRecord.type === AttendanceType.CLOCK_OUT) ? '퇴근' : '출근'}
</p>
</div>
<div className="bg-white p-4 rounded-xl shadow-sm border border-gray-100">
<p className="text-xs text-gray-400 uppercase font-semibold">시간</p>
<p className="text-lg font-bold text-gray-800">
{lastRecord ? new Date(lastRecord.timestamp).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'}) : '--:--'}
</p>
</div>
</div>
{/* Stats */}
<Stats records={records} />
</>
)}
{/* HISTORY TAB */}
{activeTab === 'history' && (
<div className="space-y-4">
<h2 className="text-lg font-bold text-gray-800">최근 활동</h2>
{records.length === 0 ? (
<p className="text-gray-500 text-center py-10">기록이 없습니다.</p>
) : (
<div className="space-y-3">
{records.map(rec => (
<div key={rec.id} className="bg-white p-4 rounded-xl shadow-sm border border-gray-100 flex justify-between items-center">
<div className="flex items-center gap-3">
<div className={`w-2 h-10 rounded-full ${rec.type === AttendanceType.CLOCK_IN ? 'bg-blue-500' : 'bg-pink-500'}`} />
<div>
<p className="font-semibold text-gray-800 text-sm">
{rec.type === AttendanceType.CLOCK_IN ? '출근' : '퇴근'}
</p>
<p className="text-xs text-gray-400">
{new Date(rec.timestamp).toLocaleDateString('ko-KR')}
</p>
</div>
</div>
<div className="text-right">
<p className="font-mono font-medium text-gray-700">
{new Date(rec.timestamp).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit'})}
</p>
<div className="flex items-center justify-end gap-1 text-xs text-gray-400">
<i data-lucide="map-pin" className="w-3 h-3"></i>
{rec.isVerified ? (
<span className="text-green-600">인증됨</span>
) : (
<span className="text-orange-500">원격</span>
)}
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
{/* SETTINGS TAB */}
{activeTab === 'settings' && (
<div className="space-y-4">
<h2 className="text-lg font-bold text-gray-800">설정</h2>
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">사무실 좌표</label>
<div className="grid grid-cols-2 gap-2 text-sm text-gray-500 bg-gray-50 p-3 rounded-lg border border-gray-200">
<div>위도: {office.latitude.toFixed(4)}</div>
<div>경도: {office.longitude.toFixed(4)}</div>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">허용 반경 (미터)</label>
<input
type="number"
value={office.allowedRadiusMeters}
onChange={(e) => {
const newOffice = { ...office, allowedRadiusMeters: parseInt(e.target.value) || 100 };
setOffice(newOffice);
saveOfficeConfig(newOffice);
}}
className="w-full p-2 border border-gray-300 rounded-lg"
min="10"
max="10000"
/>
</div>
<button
onClick={handleSetCurrentLocationAsOffice}
disabled={!currentLocation}
className="w-full bg-gray-900 text-white py-2 rounded-lg text-sm font-medium hover:bg-gray-800 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
현재 위치를 사무실로 설정
</button>
<p className="text-xs text-gray-500 text-center">
기본 좌표가 아닌 위치에서 테스트할 사용하세요.
</p>
</div>
<div className="bg-white p-6 rounded-xl shadow-sm border border-gray-100">
<h3 className="font-medium text-gray-800 mb-2">시스템 상태 확인</h3>
<div className="text-xs font-mono text-gray-500 space-y-2">
<div className="flex justify-between">
<span>GPS 권한:</span>
<span className={`font-bold ${permissionStatus === 'granted' ? 'text-green-600' : 'text-red-500'}`}>
{permissionStatus === 'granted' ? '허용됨' : permissionStatus === 'prompt' ? '대기중(물어봄)' : '거부됨'}
</span>
</div>
<div className="flex justify-between">
<span>보안 연결(HTTPS):</span>
<span className={`font-bold ${isSecure ? 'text-green-600' : 'text-red-500'}`}>
{isSecure ? '안전함' : '불안전(HTTP)'}
</span>
</div>
{!isSecure && (
<p className="text-red-500 bg-red-50 p-2 rounded">
주의: HTTPS가 아니면 최신 브라우저에서 GPS가 차단됩니다.
</p>
)}
<p>위치 모드: {geoMode}</p>
<p>재시도 횟수: {geoRetryCount}</p>
<p>마지막 수신: {lastLocationAt ? new Date(lastLocationAt).toLocaleTimeString([], {hour: '2-digit', minute: '2-digit', second: '2-digit'}) : '없음'}</p>
<p>거리: {distance ? distance.toFixed(1) : '알 수 없음'} 미터</p>
<p>반경 제한: {office.allowedRadiusMeters} 미터</p>
<p>GPS 정확도: {currentLocation?.accuracy ? currentLocation.accuracy.toFixed(1) + 'm' : '알 수 없음'}</p>
</div>
</div>
</div>
)}
</main>
{/* Bottom Navigation */}
<nav className="fixed bottom-0 w-full max-w-md bg-white border-t border-gray-200 flex justify-around items-center py-3 z-20">
<button
onClick={() => setActiveTab('home')}
className={`flex flex-col items-center gap-1 ${activeTab === 'home' ? 'text-blue-600' : 'text-gray-400 hover:text-gray-600'}`}
>
<i data-lucide="map-pin" className={`w-6 h-6 ${activeTab === 'home' ? 'fill-blue-100' : ''}`}></i>
<span className="text-[10px] font-medium">출퇴근</span>
</button>
<button
onClick={() => setActiveTab('history')}
className={`flex flex-col items-center gap-1 ${activeTab === 'history' ? 'text-blue-600' : 'text-gray-400 hover:text-gray-600'}`}
>
<i data-lucide="history" className="w-6 h-6"></i>
<span className="text-[10px] font-medium">기록</span>
</button>
<button
onClick={() => setActiveTab('settings')}
className={`flex flex-col items-center gap-1 ${activeTab === 'settings' ? 'text-blue-600' : 'text-gray-400 hover:text-gray-600'}`}
>
<i data-lucide="settings" className="w-6 h-6"></i>
<span className="text-[10px] font-medium">설정</span>
</button>
</nav>
</div>
);
};
const root = ReactDOM.createRoot(document.getElementById('root'));
root.render(<App />);
</script>
</body>
</html>