Files
GLOC/app/Http/Controllers/GeoTrackController.php
2026-01-23 19:18:52 +07:00

712 lines
24 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Models\User;
use App\Models\SalesRoute;
use App\Models\SalesRouteSummary;
use App\Models\Waypoint;
use App\Helpers\GeoHelper;
use Illuminate\Http\Request;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Hash;
use Carbon\Carbon;
class GeoTrackController extends Controller
{
/**
* GET /api/sales-routes
* Get all routes with optional filters
*/
public function index(Request $request): JsonResponse
{
$query = SalesRoute::with('user:id,employee_id,name,color');
// Filter by date
if ($request->has('date')) {
$query->whereDate('date', $request->date);
}
// Filter by salesId
// Filter by salesId
if ($request->has('salesId')) {
$user = User::where('employee_id', $request->salesId)->first(['id']);
if ($user) {
$query->where('user_id', $user->id);
} else {
// If user not found with that employee_id, return empty result immediately
// preventing return of all routes or failing
$query->where('user_id', 'non_existent_id');
}
}
$routes = $query->orderBy('date', 'desc')->get();
// Format routes with waypoints
$formattedRoutes = $routes->map(function ($route) use ($request) {
$waypoints = $route->waypoints;
// Filter waypoints by time if specified
if ($request->has('timeFrom') || $request->has('timeTo')) {
$waypoints = $waypoints->filter(function ($wp) use ($request) {
$wpTime = Carbon::parse($wp->recorded_at)->format('H:i');
if ($request->timeFrom && $wpTime < $request->timeFrom)
return false;
if ($request->timeTo && $wpTime > $request->timeTo)
return false;
return true;
})->values();
}
return [
'id' => (string) $route->id,
'salesId' => $route->user->employee_id,
'salesName' => $route->user->name,
'salesColor' => $route->user->color,
'date' => $route->date->format('Y-m-d'),
'waypoints' => $waypoints->map(function ($wp) {
return [
'lat' => (float) $wp->latitude,
'lng' => (float) $wp->longitude,
'time' => Carbon::parse($wp->recorded_at)->format('H:i'),
'location' => $wp->location_name ?? 'GPS Point',
'type' => $wp->type,
];
})->values(),
];
})->filter(fn($route) => count($route['waypoints']) > 0)->values();
return response()->json([
'success' => true,
'summary' => [
'totalRoutes' => $formattedRoutes->count(),
'dateRange' => $request->date ?? 'all',
'timeRange' => ($request->timeFrom || $request->timeTo)
? ($request->timeFrom ?? '00:00') . ' - ' . ($request->timeTo ?? '23:59')
: 'all',
],
'routes' => $formattedRoutes,
]);
}
/**
* GET /api/sales-routes/dates
* Get available dates
*/
public function dates(): JsonResponse
{
// MongoDB Aggregation to get unique dates
$rawDates = SalesRoute::raw(function ($collection) {
return $collection->aggregate([
[
'$project' => [
'dateOnly' => ['$dateToString' => ['format' => '%Y-%m-%d', 'date' => '$date']]
]
],
[
'$group' => [
'_id' => '$dateOnly'
]
],
[
'$sort' => ['_id' => -1]
]
]);
});
$dates = collect($rawDates)->map(function ($item) {
return $item['_id'];
})->values();
return response()->json([
'success' => true,
'dates' => $dates,
]);
}
/**
* GET /api/sales-routes/sales
* Get sales list
*/
public function sales(): JsonResponse
{
$users = User::where('is_active', true)
->where('role', 'sales')
->get(['id', 'employee_id', 'name', 'color']);
return response()->json([
'success' => true,
'salesList' => $users->map(fn($u) => [
'id' => $u->employee_id,
'name' => $u->name,
'color' => $u->color,
]),
]);
}
/**
* GET /api/sales-routes/{id}
* Get single route
*/
public function show(string $id): JsonResponse
{
$route = SalesRoute::with(['user:id,employee_id,name,color', 'waypoints'])->find($id);
if (!$route) {
return response()->json(['success' => false, 'error' => 'Route not found'], 404);
}
return response()->json([
'success' => true,
'route' => [
'id' => (string) $route->id,
'salesId' => $route->user->employee_id,
'salesName' => $route->user->name,
'salesColor' => $route->user->color,
'date' => $route->date->format('Y-m-d'),
'waypoints' => $route->waypoints->map(function ($wp) {
return [
'lat' => (float) $wp->latitude,
'lng' => (float) $wp->longitude,
'time' => Carbon::parse($wp->recorded_at)->format('H:i'),
'location' => $wp->location_name ?? 'GPS Point',
'type' => $wp->type,
];
}),
],
]);
}
/**
* POST /api/sales-routes/waypoints
* Create waypoint (for mobile app)
*/
public function storeWaypoint(Request $request): JsonResponse
{
$request->validate([
'user_id' => 'required|string',
'type' => 'required|in:checkin,checkout,gps,lunch,visit',
'latitude' => 'required|numeric',
'longitude' => 'required|numeric',
]);
// Find user
$user = User::where('employee_id', $request->user_id)->first();
if (!$user) {
return response()->json(['success' => false, 'error' => 'User not found'], 404);
}
// Get or create today's route
$today = Carbon::today();
$route = SalesRoute::firstOrCreate(
['user_id' => $user->id, 'date' => $today],
['status' => 'active', 'started_at' => now()]
);
// Create waypoint
$waypoint = Waypoint::create([
'sales_route_id' => $route->id,
'type' => $request->type,
'latitude' => $request->latitude,
'longitude' => $request->longitude,
'recorded_at' => now(),
'location_name' => $request->location_name,
'address' => $request->address,
'notes' => $request->notes,
'photo_url' => $request->photo_url,
]);
// If checkout, update route status
if ($request->type === 'checkout') {
$waypoints = $route->waypoints()->get()->toArray();
$totalDistance = GeoHelper::calculateRouteDistance($waypoints);
$firstWp = $route->waypoints()->orderBy('recorded_at')->first();
$durationMinutes = $firstWp ? now()->diffInMinutes($firstWp->recorded_at) : 0;
$visitCount = $route->waypoints()->where('type', 'visit')->count();
$route->update([
'status' => 'completed',
'ended_at' => now(),
'total_distance_km' => $totalDistance,
'total_distance_km' => $totalDistance,
'total_duration_minutes' => $durationMinutes,
'total_visits' => $visitCount,
]);
// Save to Sales Link Summary
SalesRouteSummary::create([
'user_id' => $user->id,
'sales_route_id' => $route->id,
'date' => $today,
'total_distance_km' => $totalDistance,
'total_duration_minutes' => $durationMinutes,
'total_visits' => $visitCount,
'started_at' => $route->started_at,
'ended_at' => now(),
]);
}
return response()->json([
'success' => true,
'data' => [
'id' => $waypoint->id,
'sales_route_id' => $route->id,
'type' => $waypoint->type,
'latitude' => $waypoint->latitude,
'longitude' => $waypoint->longitude,
'recorded_at' => $waypoint->recorded_at,
'location_name' => $waypoint->location_name,
],
'message' => 'Waypoint recorded successfully',
]);
}
// ============================================================
// MOBILE API ENDPOINTS (For Flutter App)
// ============================================================
/**
* POST /api/mobile/login
* Authenticate user and return settings + profile
*/
public function mobileLogin(Request $request): JsonResponse
{
$request->validate([
'username' => 'required|string',
'password' => 'required',
]);
// Login supports both email and employee_id (username) - case insensitive
$username = strtolower($request->username);
// Find user by email or employee_id
$user = User::where('email', $username)
->orWhere('employee_id', $username)
->first();
// Check password (Hash or plain text for legacy/demo)
if (!$user) {
return response()->json(['success' => false, 'error' => 'Invalid credentials'], 401);
}
// 1. Check Hashed Password (New Standard)
if (Hash::check($request->password, $user->password)) {
// Valid
}
// 2. Fallback: Check hardcoded 'password' for demo users who haven't changed pass yet
// OR check plain text match if DB seeded with plain text
else if ($request->password === 'password' || $request->password === $user->password) {
// Valid (Legacy/Demo) - You might want to auto-hash it here if you want to migrate
} else {
return response()->json(['success' => false, 'error' => 'Invalid credentials'], 401);
}
if (!$user) {
return response()->json(['success' => false, 'error' => 'Invalid credentials'], 401);
}
return response()->json([
'success' => true,
'user' => [
'id' => (string) $user->id,
'employee_id' => $user->employee_id,
'name' => $user->name,
'email' => $user->email,
'role' => $user->role,
'color' => $user->color,
],
'company' => [
'name' => 'GeoReach Intelligence',
'address' => 'Jakarta, Indonesia',
'logo_url' => 'assets/logo.png',
],
'settings' => [
'local_storage_interval_seconds' => 300, // 5 minutes (Save to local)
'server_sync_interval_seconds' => 1800, // 30 minutes (Submit to server)
'gps_accuracy_filter_meters' => 50,
],
'message' => 'Login successful',
]);
}
/**
* POST /api/mobile/checkin
* Clock in / start tracking for today
*/
public function mobileCheckin(Request $request): JsonResponse
{
$request->validate([
'user_id' => 'required|string',
'latitude' => 'required|numeric',
'longitude' => 'required|numeric',
]);
// Find user
$user = User::where('employee_id', $request->user_id)->first();
if (!$user) {
return response()->json(['success' => false, 'error' => 'User not found'], 404);
}
$today = Carbon::today();
// Check if already checked in today
$existingRoute = SalesRoute::where('user_id', $user->id)
->whereDate('date', $today)
->first();
if ($existingRoute && $existingRoute->status === 'active') {
return response()->json([
'success' => false,
'error' => 'Already checked in today',
'route_id' => $existingRoute->id,
], 409);
}
// Create new route for today
$route = SalesRoute::create([
'user_id' => $user->id,
'date' => $today,
'status' => 'active',
'started_at' => now(),
]);
// Create check-in waypoint
Waypoint::create([
'sales_route_id' => $route->id,
'type' => 'checkin',
'latitude' => $request->latitude,
'longitude' => $request->longitude,
'recorded_at' => now(),
'location_name' => $request->location_name ?? 'Check-in Point',
'notes' => $request->device_info,
]);
return response()->json([
'success' => true,
'route_id' => $route->id,
'checked_in_at' => now()->format('Y-m-d H:i:s'),
'message' => 'Check-in successful',
]);
}
/**
* POST /api/mobile/checkout
* Clock out / stop tracking
*/
public function mobileCheckout(Request $request): JsonResponse
{
$request->validate([
'user_id' => 'required|string',
'latitude' => 'required|numeric',
'longitude' => 'required|numeric',
]);
// Find user
$user = User::where('employee_id', $request->user_id)->first();
if (!$user) {
return response()->json(['success' => false, 'error' => 'User not found'], 404);
}
$today = Carbon::today();
// Find active route (from today or previous days)
$route = SalesRoute::where('user_id', $user->id)
->where('status', 'active')
->orderBy('date', 'desc') // Get latest if multiple?
->first();
if (!$route) {
return response()->json([
'success' => false,
'error' => 'No active route found. Please check-in first. (User: ' . $user->employee_id . ')',
], 400);
}
// Create checkout waypoint
Waypoint::create([
'sales_route_id' => $route->id,
'type' => 'checkout',
'latitude' => $request->latitude,
'longitude' => $request->longitude,
'recorded_at' => now(),
'location_name' => $request->location_name ?? 'Check-out Point',
]);
// Calculate totals
$waypoints = $route->waypoints()->get()->toArray();
$totalDistance = GeoHelper::calculateRouteDistance($waypoints);
$firstWp = $route->waypoints()->orderBy('recorded_at')->first();
$durationMinutes = $firstWp ? now()->diffInMinutes($firstWp->recorded_at) : 0;
$visitCount = $route->waypoints()->where('type', 'visit')->count();
// Update route status
$route->update([
'status' => 'completed',
'ended_at' => now(),
'total_distance_km' => $totalDistance,
'total_duration_minutes' => $durationMinutes,
'total_visits' => $visitCount,
]);
// Save to Sales Link Summary
SalesRouteSummary::create([
'user_id' => $user->id,
'sales_route_id' => $route->id,
'date' => $today,
'total_distance_km' => $totalDistance,
'total_duration_minutes' => $durationMinutes,
'total_visits' => $visitCount,
'started_at' => $route->started_at,
'ended_at' => now(),
]);
return response()->json([
'success' => true,
'message' => 'Check-out successful',
'summary' => [
'route_id' => $route->id,
'total_distance_km' => $totalDistance,
'total_duration_minutes' => $durationMinutes,
'total_visits' => $visitCount,
'waypoints_count' => count($waypoints),
'checked_out_at' => now()->format('Y-m-d H:i:s'),
],
]);
}
/**
* POST /api/mobile/waypoints/batch
* Batch submit waypoints (for offline support)
*/
public function storeBatchWaypoints(Request $request): JsonResponse
{
$request->validate([
'user_id' => 'required|string',
'waypoints' => 'required|array|min:1',
'waypoints.*.type' => 'required|in:gps,visit,lunch,checkin,checkout',
'waypoints.*.latitude' => 'required|numeric',
'waypoints.*.longitude' => 'required|numeric',
]);
// Find user
$user = User::where('employee_id', $request->user_id)->first();
if (!$user) {
return response()->json(['success' => false, 'error' => 'User not found'], 404);
}
$today = Carbon::today();
// Get or create today's route
$route = SalesRoute::firstOrCreate(
['user_id' => $user->id, 'date' => $today],
['status' => 'active', 'started_at' => now()]
);
$inserted = 0;
foreach ($request->waypoints as $wp) {
Waypoint::create([
'sales_route_id' => $route->id,
'type' => $wp['type'],
'latitude' => $wp['latitude'],
'longitude' => $wp['longitude'],
'recorded_at' => isset($wp['recorded_at']) ? Carbon::parse($wp['recorded_at']) : now(),
'location_name' => $wp['location_name'] ?? null,
'address' => $wp['address'] ?? null,
'notes' => $wp['notes'] ?? null,
'photo_url' => $wp['photo_url'] ?? null,
]);
$inserted++;
}
return response()->json([
'success' => true,
'inserted' => $inserted,
'route_id' => $route->id,
'message' => "{$inserted} waypoints recorded successfully",
]);
}
/**
* GET /api/mobile/route/today
* Get today's route for user
*/
public function getTodayRoute(Request $request): JsonResponse
{
$request->validate([
'user_id' => 'required|string',
]);
// Find user
$user = User::where('employee_id', $request->user_id)->first();
if (!$user) {
return response()->json(['success' => false, 'error' => 'User not found'], 404);
}
$today = Carbon::today();
$route = SalesRoute::with('waypoints')
->where('user_id', $user->id)
->whereDate('date', $today)
->first();
if (!$route) {
return response()->json([
'success' => true,
'route' => null,
'message' => 'No route found for today',
]);
}
return response()->json([
'success' => true,
'route' => [
'id' => $route->id,
'date' => $route->date->format('Y-m-d'),
'status' => $route->status,
'started_at' => $route->started_at?->format('Y-m-d H:i:s'),
'ended_at' => $route->ended_at?->format('Y-m-d H:i:s'),
'total_distance_km' => $route->total_distance_km,
'total_duration_minutes' => $route->total_duration_minutes,
'waypoints' => $route->waypoints->map(fn($wp) => [
'id' => $wp->id,
'type' => $wp->type,
'lat' => (float) $wp->latitude,
'lng' => (float) $wp->longitude,
'time' => Carbon::parse($wp->recorded_at)->format('Y-m-d H:i:s'),
'location' => $wp->location_name ?? 'GPS Point',
'notes' => $wp->notes,
]),
],
]);
}
/**
* GET /api/mobile/status
* Get current tracking status for user
*/
public function getTrackingStatus(Request $request): JsonResponse
{
$request->validate([
'user_id' => 'required|string',
]);
// Find user
$user = User::where('employee_id', $request->user_id)->first();
if (!$user) {
return response()->json(['success' => false, 'error' => 'User not found'], 404);
}
$today = Carbon::today();
// MongoDB compatible: get route without withCount
$route = SalesRoute::where('user_id', $user->id)
->whereDate('date', $today)
->first();
if (!$route) {
return response()->json([
'success' => true,
'is_checked_in' => false,
'route_id' => null,
'checked_in_at' => null,
'waypoints_today' => 0,
]);
}
// Count waypoints manually for MongoDB compatibility
$waypointsCount = $route->waypoints()->count();
return response()->json([
'success' => true,
'is_checked_in' => $route->status === 'active',
'is_completed' => $route->status === 'completed',
'route_id' => $route->id,
'checked_in_at' => $route->started_at?->format('Y-m-d H:i:s'),
'checked_out_at' => $route->ended_at?->format('Y-m-d H:i:s'),
'waypoints_today' => $waypointsCount,
'total_distance_km' => $route->total_distance_km,
]);
}
/**
* POST /api/mobile/profile
* Update user profile
*/
public function updateProfile(Request $request): JsonResponse
{
$request->validate([
'user_id' => 'required|string', // Employee ID or User ID
'name' => 'sometimes|string',
'email' => 'sometimes|email',
'phone' => 'sometimes|string',
]);
$user = User::where('employee_id', $request->user_id)->orWhere('id', $request->user_id)->first();
if (!$user) {
return response()->json(['success' => false, 'error' => 'User not found'], 404);
}
if ($request->has('name'))
$user->name = $request->name;
if ($request->has('email'))
$user->email = $request->email;
if ($request->has('phone'))
$user->phone = $request->phone;
$user->save();
return response()->json([
'success' => true,
'message' => 'Profile updated successfully',
'user' => [
'id' => (string) $user->id,
'employee_id' => $user->employee_id,
'name' => $user->name,
'email' => $user->email,
'phone' => $user->phone,
'role' => $user->role,
'color' => $user->color,
],
]);
}
/**
* POST /api/mobile/change-password
* Change user password
*/
public function changePassword(Request $request): JsonResponse
{
$request->validate([
'user_id' => 'required|string',
'current_password' => 'required',
'new_password' => 'required|min:8', // Add regex if strict is needed
]);
$user = User::where('employee_id', $request->user_id)->orWhere('id', $request->user_id)->first();
if (!$user) {
return response()->json(['success' => false, 'error' => 'User not found'], 404);
}
// Verify current password
// Check hash OR Check basic 'password' fallback
$isValidCurrent = Hash::check($request->current_password, $user->password)
|| ($request->current_password === 'password' && !$user->password) // If initial state
|| $request->current_password === $user->password;
if (!$isValidCurrent) {
return response()->json(['success' => false, 'error' => 'Password lama salah'], 401);
}
// Update to new hashed password
$user->password = Hash::make($request->new_password);
$user->save();
return response()->json([
'success' => true,
'message' => 'Password berhasil diubah',
]);
}
}