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', ]); } }