- URL 하드코딩 → .env APP_URL 기반 동적 URL로 변경 - DB 연결 하드코딩 → .env 기반으로 변경 - MySQL strict mode DATE 오류 수정
487 lines
25 KiB
PHP
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>
|
|
|