Files
sam-kd/geoattendance/index.php
hskwon aca1767eb9 초기 커밋: 5130 레거시 시스템
- URL 하드코딩 → .env APP_URL 기반 동적 URL로 변경
- DB 연결 하드코딩 → .env 기반으로 변경
- MySQL strict mode DATE 오류 수정
2025-12-10 20:14:31 +09:00

487 lines
25 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 GeoWork - 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 [distance, setDistance] = useState(null);
const [activeTab, setActiveTab] = useState('home');
useEffect(() => {
lucide.createIcons();
}, [activeTab, records]);
// Load records on mount
useEffect(() => {
loadRecords();
loadOfficeConfig();
}, []);
// Start watching location
useEffect(() => {
if ('geolocation' in navigator) {
const watchId = navigator.geolocation.watchPosition(
(position) => {
const newLoc = {
latitude: position.coords.latitude,
longitude: position.coords.longitude,
accuracy: position.coords.accuracy
};
setCurrentLocation(newLoc);
setErrorMsg(null);
},
(err) => {
console.error(err);
setErrorMsg("Unable to retrieve location. Please enable GPS.");
},
{ enableHighAccuracy: true, maximumAge: 10000, timeout: 5000 }
);
return () => navigator.geolocation.clearWatch(watchId);
} else {
setErrorMsg("Geolocation is not supported by your browser.");
}
}, []);
// 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">
{/* 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-1">
<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>